github-sync-agent 0.1.0__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.
@@ -0,0 +1,132 @@
1
+ Metadata-Version: 2.4
2
+ Name: github-sync-agent
3
+ Version: 0.1.0
4
+ Summary: Sync all your local projects to GitHub automatically
5
+ Author: yuvalavni
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/yuvalavni/Agents
8
+ Project-URL: Repository, https://github.com/yuvalavni/Agents
9
+ Keywords: github,git,sync,backup,automation,cli
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: POSIX :: Linux
13
+ Classifier: Environment :: Console
14
+ Classifier: Topic :: Software Development :: Version Control :: Git
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: pyyaml>=6.0
18
+ Requires-Dist: click>=8.0
19
+
20
+ # GitHub Sync Agent
21
+
22
+ Automatically syncs every project under `/home/yuval/projects` to GitHub (`github.com/yuvalavni`).
23
+
24
+ For each project it will:
25
+ 1. `git init` if not already a repo
26
+ 2. Create the GitHub repo if it doesn't exist yet
27
+ 3. Ensure the remote uses SSH
28
+ 4. Stage + commit any uncommitted changes
29
+ 5. Push to `origin`
30
+
31
+ Runs daily at **02:00** via a systemd user timer, and on-demand via CLI.
32
+
33
+ ---
34
+
35
+ ## Directory layout
36
+
37
+ ```
38
+ agents/github-sync/
39
+ ├── github_sync_agent.py # Main script
40
+ ├── config.yaml # GitHub user, projects root, schedule, exclusions
41
+ ├── requirements.txt
42
+ ├── install.sh # One-time setup
43
+ ├── systemd/
44
+ │ ├── github-sync.service
45
+ │ └── github-sync.timer
46
+ └── logs/
47
+ └── github-sync.log
48
+ ```
49
+
50
+ ---
51
+
52
+ ## Quick start
53
+
54
+ ```bash
55
+ cd /home/yuval/projects/Agents/agents/github-sync
56
+
57
+ # 1. Install (once)
58
+ bash install.sh
59
+
60
+ # 2. Dry run
61
+ .venv/bin/python github_sync_agent.py --dry-run
62
+
63
+ # 3. Sync for real
64
+ .venv/bin/python github_sync_agent.py
65
+ ```
66
+
67
+ ---
68
+
69
+ ## Configuration (`config.yaml`)
70
+
71
+ | Key | Default | Description |
72
+ |-----|---------|-------------|
73
+ | `github_user` | `yuvalavni` | GitHub username |
74
+ | `projects_root` | `/home/yuval/projects` | Scans all subdirectories here |
75
+ | `schedule_time` | `02:00` | Daily run time |
76
+ | `default_visibility` | `private` | `private` or `public` for new repos |
77
+ | `auto_commit_message` | `chore: auto-sync` | Commit message for auto-commits |
78
+ | `exclude` | `[]` | Directory names to skip |
79
+ | `log_file` | `logs/github-sync.log` | Log file path |
80
+ | `log_level` | `INFO` | `DEBUG` / `INFO` / `WARNING` / `ERROR` |
81
+
82
+ ### Excluding a project
83
+
84
+ ```yaml
85
+ exclude:
86
+ - SomePrivateProject
87
+ - TempExperiment
88
+ ```
89
+
90
+ ---
91
+
92
+ ## CLI reference
93
+
94
+ ```bash
95
+ # Sync all projects
96
+ .venv/bin/python github_sync_agent.py
97
+
98
+ # Sync a single project
99
+ .venv/bin/python github_sync_agent.py --project Agents
100
+
101
+ # Preview without making any changes
102
+ .venv/bin/python github_sync_agent.py --dry-run
103
+
104
+ # Show status of all projects (git, remote, changes, GitHub)
105
+ .venv/bin/python github_sync_agent.py --status
106
+ ```
107
+
108
+ ---
109
+
110
+ ## Systemd
111
+
112
+ ```bash
113
+ # Trigger immediately
114
+ systemctl --user start github-sync.service
115
+
116
+ # Check next scheduled run
117
+ systemctl --user list-timers github-sync.timer
118
+
119
+ # View live logs
120
+ journalctl --user -u github-sync.service -f
121
+
122
+ # Disable daily timer
123
+ systemctl --user disable --now github-sync.timer
124
+ ```
125
+
126
+ ---
127
+
128
+ ## Prerequisites
129
+
130
+ - `git` installed
131
+ - `gh` installed and authenticated (`gh auth status`)
132
+ - SSH key added to GitHub (`gh ssh-key list`)
@@ -0,0 +1,113 @@
1
+ # GitHub Sync Agent
2
+
3
+ Automatically syncs every project under `/home/yuval/projects` to GitHub (`github.com/yuvalavni`).
4
+
5
+ For each project it will:
6
+ 1. `git init` if not already a repo
7
+ 2. Create the GitHub repo if it doesn't exist yet
8
+ 3. Ensure the remote uses SSH
9
+ 4. Stage + commit any uncommitted changes
10
+ 5. Push to `origin`
11
+
12
+ Runs daily at **02:00** via a systemd user timer, and on-demand via CLI.
13
+
14
+ ---
15
+
16
+ ## Directory layout
17
+
18
+ ```
19
+ agents/github-sync/
20
+ ├── github_sync_agent.py # Main script
21
+ ├── config.yaml # GitHub user, projects root, schedule, exclusions
22
+ ├── requirements.txt
23
+ ├── install.sh # One-time setup
24
+ ├── systemd/
25
+ │ ├── github-sync.service
26
+ │ └── github-sync.timer
27
+ └── logs/
28
+ └── github-sync.log
29
+ ```
30
+
31
+ ---
32
+
33
+ ## Quick start
34
+
35
+ ```bash
36
+ cd /home/yuval/projects/Agents/agents/github-sync
37
+
38
+ # 1. Install (once)
39
+ bash install.sh
40
+
41
+ # 2. Dry run
42
+ .venv/bin/python github_sync_agent.py --dry-run
43
+
44
+ # 3. Sync for real
45
+ .venv/bin/python github_sync_agent.py
46
+ ```
47
+
48
+ ---
49
+
50
+ ## Configuration (`config.yaml`)
51
+
52
+ | Key | Default | Description |
53
+ |-----|---------|-------------|
54
+ | `github_user` | `yuvalavni` | GitHub username |
55
+ | `projects_root` | `/home/yuval/projects` | Scans all subdirectories here |
56
+ | `schedule_time` | `02:00` | Daily run time |
57
+ | `default_visibility` | `private` | `private` or `public` for new repos |
58
+ | `auto_commit_message` | `chore: auto-sync` | Commit message for auto-commits |
59
+ | `exclude` | `[]` | Directory names to skip |
60
+ | `log_file` | `logs/github-sync.log` | Log file path |
61
+ | `log_level` | `INFO` | `DEBUG` / `INFO` / `WARNING` / `ERROR` |
62
+
63
+ ### Excluding a project
64
+
65
+ ```yaml
66
+ exclude:
67
+ - SomePrivateProject
68
+ - TempExperiment
69
+ ```
70
+
71
+ ---
72
+
73
+ ## CLI reference
74
+
75
+ ```bash
76
+ # Sync all projects
77
+ .venv/bin/python github_sync_agent.py
78
+
79
+ # Sync a single project
80
+ .venv/bin/python github_sync_agent.py --project Agents
81
+
82
+ # Preview without making any changes
83
+ .venv/bin/python github_sync_agent.py --dry-run
84
+
85
+ # Show status of all projects (git, remote, changes, GitHub)
86
+ .venv/bin/python github_sync_agent.py --status
87
+ ```
88
+
89
+ ---
90
+
91
+ ## Systemd
92
+
93
+ ```bash
94
+ # Trigger immediately
95
+ systemctl --user start github-sync.service
96
+
97
+ # Check next scheduled run
98
+ systemctl --user list-timers github-sync.timer
99
+
100
+ # View live logs
101
+ journalctl --user -u github-sync.service -f
102
+
103
+ # Disable daily timer
104
+ systemctl --user disable --now github-sync.timer
105
+ ```
106
+
107
+ ---
108
+
109
+ ## Prerequisites
110
+
111
+ - `git` installed
112
+ - `gh` installed and authenticated (`gh auth status`)
113
+ - SSH key added to GitHub (`gh ssh-key list`)
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,192 @@
1
+ """Core sync logic — no CLI concerns here."""
2
+
3
+ import logging
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+
8
+ # ---------------------------------------------------------------------------
9
+ # Shell helpers
10
+ # ---------------------------------------------------------------------------
11
+
12
+ def _run(cmd: list[str], cwd: Path | None = None) -> tuple[bool, str]:
13
+ result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
14
+ return result.returncode == 0, (result.stdout + result.stderr).strip()
15
+
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Git helpers
19
+ # ---------------------------------------------------------------------------
20
+
21
+ def is_git_repo(path: Path) -> bool:
22
+ ok, _ = _run(["git", "rev-parse", "--git-dir"], cwd=path)
23
+ return ok
24
+
25
+
26
+ def git_init(path: Path, dry_run: bool) -> None:
27
+ if dry_run:
28
+ logging.info(f"[DRY-RUN] Would git init: {path}")
29
+ return
30
+ _run(["git", "init"], cwd=path)
31
+
32
+
33
+ def has_remote(path: Path) -> bool:
34
+ ok, _ = _run(["git", "remote", "get-url", "origin"], cwd=path)
35
+ return ok
36
+
37
+
38
+ def add_remote(path: Path, github_user: str, project: str, dry_run: bool) -> None:
39
+ url = f"git@github.com:{github_user}/{project}.git"
40
+ if dry_run:
41
+ logging.info(f"[DRY-RUN] Would add remote: {url}")
42
+ return
43
+ _run(["git", "remote", "add", "origin", url], cwd=path)
44
+
45
+
46
+ def ensure_ssh_remote(path: Path, github_user: str, project: str, dry_run: bool) -> None:
47
+ _, url = _run(["git", "remote", "get-url", "origin"], cwd=path)
48
+ if url.startswith("https://"):
49
+ ssh_url = f"git@github.com:{github_user}/{project}.git"
50
+ if dry_run:
51
+ logging.info(f"[DRY-RUN] Would switch remote to SSH: {ssh_url}")
52
+ return
53
+ _run(["git", "remote", "set-url", "origin", ssh_url], cwd=path)
54
+ logging.info(f"Switched remote to SSH")
55
+
56
+
57
+ def has_commits(path: Path) -> bool:
58
+ ok, _ = _run(["git", "rev-parse", "HEAD"], cwd=path)
59
+ return ok
60
+
61
+
62
+ def has_uncommitted_changes(path: Path) -> bool:
63
+ ok, output = _run(["git", "status", "--porcelain"], cwd=path)
64
+ return ok and bool(output.strip())
65
+
66
+
67
+ def stage_and_commit(path: Path, message: str, dry_run: bool) -> bool:
68
+ if dry_run:
69
+ logging.info(f"[DRY-RUN] Would commit: '{message}'")
70
+ return True
71
+ _run(["git", "add", "-A"], cwd=path)
72
+ ok, _ = _run(["git", "diff", "--cached", "--quiet"], cwd=path)
73
+ if ok:
74
+ return False # nothing staged
75
+ _run(["git", "commit", "-m", message], cwd=path)
76
+ return True
77
+
78
+
79
+ def push(path: Path, dry_run: bool) -> bool:
80
+ if dry_run:
81
+ logging.info("[DRY-RUN] Would push to origin")
82
+ return True
83
+ ok, out = _run(["git", "push", "-u", "origin", "HEAD"], cwd=path)
84
+ if not ok:
85
+ logging.error(f"Push failed: {out}")
86
+ return ok
87
+
88
+
89
+ def get_remote_url(path: Path) -> str:
90
+ _, url = _run(["git", "remote", "get-url", "origin"], cwd=path)
91
+ return url
92
+
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # GitHub helpers
96
+ # ---------------------------------------------------------------------------
97
+
98
+ def github_repo_exists(github_user: str, project: str) -> bool:
99
+ ok, _ = _run(["gh", "repo", "view", f"{github_user}/{project}"])
100
+ return ok
101
+
102
+
103
+ def create_github_repo(github_user: str, project: str, visibility: str, dry_run: bool) -> bool:
104
+ flag = "--private" if visibility == "private" else "--public"
105
+ if dry_run:
106
+ logging.info(f"[DRY-RUN] Would create GitHub repo: {github_user}/{project} ({visibility})")
107
+ return True
108
+ ok, out = _run(["gh", "repo", "create", f"{github_user}/{project}", flag])
109
+ if ok:
110
+ logging.info(f"Created GitHub repo: https://github.com/{github_user}/{project}")
111
+ else:
112
+ logging.error(f"Failed to create repo: {out}")
113
+ return ok
114
+
115
+
116
+ # ---------------------------------------------------------------------------
117
+ # Per-project sync
118
+ # ---------------------------------------------------------------------------
119
+
120
+ def sync_project(path: Path, cfg: dict, dry_run: bool) -> bool:
121
+ project = path.name
122
+ github_user = cfg["github_user"]
123
+
124
+ logging.info(f"── {project}")
125
+
126
+ if not is_git_repo(path):
127
+ git_init(path, dry_run)
128
+
129
+ if not github_repo_exists(github_user, project):
130
+ if not create_github_repo(github_user, project, cfg["default_visibility"], dry_run):
131
+ return False
132
+
133
+ if not has_remote(path):
134
+ add_remote(path, github_user, project, dry_run)
135
+ else:
136
+ ensure_ssh_remote(path, github_user, project, dry_run)
137
+
138
+ if not has_commits(path) or has_uncommitted_changes(path):
139
+ stage_and_commit(path, cfg["auto_commit_message"], dry_run)
140
+
141
+ if not has_commits(path):
142
+ logging.warning(" No commits yet — nothing to push.")
143
+ return True
144
+
145
+ ok = push(path, dry_run)
146
+ if ok:
147
+ logging.info(" ✓ Synced")
148
+ return ok
149
+
150
+
151
+ # ---------------------------------------------------------------------------
152
+ # Discover projects
153
+ # ---------------------------------------------------------------------------
154
+
155
+ def discover_projects(cfg: dict, project_name: str | None = None) -> list[Path]:
156
+ root = Path(cfg["projects_root"])
157
+ exclude = set(cfg.get("exclude", []))
158
+
159
+ if project_name:
160
+ path = root / project_name
161
+ if not path.is_dir():
162
+ raise ValueError(f"Project '{project_name}' not found in {root}")
163
+ return [path]
164
+
165
+ return sorted(
166
+ p for p in root.iterdir()
167
+ if p.is_dir() and p.name not in exclude and not p.name.startswith(".")
168
+ )
169
+
170
+
171
+ # ---------------------------------------------------------------------------
172
+ # Status table
173
+ # ---------------------------------------------------------------------------
174
+
175
+ def get_status(cfg: dict) -> list[dict]:
176
+ root = Path(cfg["projects_root"])
177
+ exclude = set(cfg.get("exclude", []))
178
+ github_user = cfg["github_user"]
179
+ rows = []
180
+
181
+ for path in sorted(root.iterdir()):
182
+ if not path.is_dir() or path.name in exclude or path.name.startswith("."):
183
+ continue
184
+ rows.append({
185
+ "project": path.name,
186
+ "git": is_git_repo(path),
187
+ "remote": is_git_repo(path) and has_remote(path),
188
+ "changes": has_uncommitted_changes(path) if is_git_repo(path) else False,
189
+ "on_github": github_repo_exists(github_user, path.name),
190
+ })
191
+
192
+ return rows
@@ -0,0 +1,234 @@
1
+ import logging
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ import click
6
+
7
+ from . import __version__
8
+ from . import agent, config, setup
9
+
10
+ LOG_FORMAT = "%(asctime)s %(levelname)-8s %(message)s"
11
+
12
+
13
+ def _setup_logging(cfg: dict) -> None:
14
+ log_file = cfg["log_file"]
15
+ Path(log_file).parent.mkdir(parents=True, exist_ok=True)
16
+ logging.basicConfig(
17
+ level=getattr(logging, cfg["log_level"].upper(), logging.INFO),
18
+ format=LOG_FORMAT,
19
+ handlers=[
20
+ logging.StreamHandler(sys.stdout),
21
+ logging.FileHandler(log_file, encoding="utf-8"),
22
+ ],
23
+ )
24
+
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Root command group
28
+ # ---------------------------------------------------------------------------
29
+
30
+ @click.group()
31
+ @click.version_option(__version__, prog_name="github-sync")
32
+ @click.option("--config", "config_path", type=click.Path(path_type=Path),
33
+ default=None, help="Path to config.yaml")
34
+ @click.pass_context
35
+ def cli(ctx: click.Context, config_path: Path | None) -> None:
36
+ """GitHub Sync Agent — sync local projects to GitHub."""
37
+ ctx.ensure_object(dict)
38
+ try:
39
+ ctx.obj["cfg"] = config.load(config_path)
40
+ except (FileNotFoundError, ValueError) as exc:
41
+ click.echo(f"Error: {exc}", err=True)
42
+ sys.exit(1)
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # sync
47
+ # ---------------------------------------------------------------------------
48
+
49
+ @cli.command()
50
+ @click.option("--project", "-p", default=None,
51
+ help="Sync a single project by name (default: all)")
52
+ @click.option("--dry-run", is_flag=True,
53
+ help="Show what would happen without making changes")
54
+ @click.pass_context
55
+ def sync(ctx: click.Context, project: str | None, dry_run: bool) -> None:
56
+ """Sync projects to GitHub."""
57
+ cfg = ctx.obj["cfg"]
58
+ _setup_logging(cfg)
59
+
60
+ if dry_run:
61
+ logging.info("=== DRY-RUN — no changes will be made ===")
62
+
63
+ try:
64
+ projects = agent.discover_projects(cfg, project)
65
+ except ValueError as exc:
66
+ click.echo(f"Error: {exc}", err=True)
67
+ sys.exit(1)
68
+
69
+ logging.info(f"Syncing {len(projects)} project(s) → github.com/{cfg['github_user']}")
70
+
71
+ results = []
72
+ for path in projects:
73
+ ok = agent.sync_project(path, cfg, dry_run)
74
+ results.append((path.name, ok))
75
+
76
+ successes = sum(1 for _, ok in results if ok)
77
+ failures = len(results) - successes
78
+ logging.info(f"Done: {successes} succeeded, {failures} failed.")
79
+
80
+ if failures:
81
+ for name, ok in results:
82
+ if not ok:
83
+ logging.error(f" FAILED: {name}")
84
+ sys.exit(1)
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # status
89
+ # ---------------------------------------------------------------------------
90
+
91
+ @cli.command()
92
+ @click.pass_context
93
+ def status(ctx: click.Context) -> None:
94
+ """Show sync status for all projects."""
95
+ cfg = ctx.obj["cfg"]
96
+ rows = agent.get_status(cfg)
97
+
98
+ click.echo(f"\nProjects root : {cfg['projects_root']}")
99
+ click.echo(f"GitHub user : {cfg['github_user']}\n")
100
+
101
+ header = f"{'Project':<25} {'Git':<6} {'Remote':<8} {'Changes':<10} {'On GitHub'}"
102
+ click.echo(header)
103
+ click.echo("─" * len(header))
104
+
105
+ for row in rows:
106
+ click.echo(
107
+ f"{row['project']:<25} "
108
+ f"{'✓' if row['git'] else '✗':<6} "
109
+ f"{'✓' if row['remote'] else '✗':<8} "
110
+ f"{'dirty' if row['changes'] else 'clean':<10} "
111
+ f"{'✓' if row['on_github'] else '✗'}"
112
+ )
113
+ click.echo()
114
+
115
+
116
+ # ---------------------------------------------------------------------------
117
+ # init
118
+ # ---------------------------------------------------------------------------
119
+
120
+ @cli.command()
121
+ def init() -> None:
122
+ """Interactive setup wizard — run this first on a new machine."""
123
+ click.echo()
124
+ click.echo(click.style("=== GitHub Sync Agent — Setup Wizard ===", bold=True))
125
+ click.echo()
126
+
127
+ # ── 1. Check git ────────────────────────────────────────────────────────
128
+ git_ok, git_ver = setup.check_git()
129
+ if git_ok:
130
+ click.echo(f" ✓ git found: {git_ver}")
131
+ else:
132
+ click.echo(click.style(" ✗ git is not installed.", fg="red"))
133
+ click.echo(" Install it with: sudo apt-get install -y git")
134
+ sys.exit(1)
135
+
136
+ # ── 2. Check gh ─────────────────────────────────────────────────────────
137
+ gh_ok, gh_ver = setup.check_gh()
138
+ if gh_ok:
139
+ click.echo(f" ✓ gh found: {gh_ver}")
140
+ else:
141
+ click.echo(click.style(" ✗ gh (GitHub CLI) is not installed.", fg="red"))
142
+ click.echo(" Install it with: sudo apt-get install -y gh")
143
+ sys.exit(1)
144
+
145
+ # ── 3. GitHub authentication ─────────────────────────────────────────────
146
+ if setup.gh_authenticated():
147
+ github_user = setup.get_github_username()
148
+ click.echo(f" ✓ Authenticated as: {github_user}")
149
+ else:
150
+ click.echo(click.style(" ✗ Not authenticated with GitHub.", fg="yellow"))
151
+ click.echo(" Running: gh auth login")
152
+ click.echo()
153
+ import subprocess
154
+ subprocess.run(["gh", "auth", "login"])
155
+ github_user = setup.get_github_username()
156
+ if not github_user:
157
+ click.echo(click.style("Authentication failed. Exiting.", fg="red"))
158
+ sys.exit(1)
159
+ click.echo(f" ✓ Authenticated as: {github_user}")
160
+
161
+ click.echo()
162
+
163
+ # ── 4. Projects root ────────────────────────────────────────────────────
164
+ default_root = str(Path.home() / "projects")
165
+ projects_root = click.prompt(
166
+ " Projects root directory (all subdirs will be synced)",
167
+ default=default_root,
168
+ )
169
+ projects_root = str(Path(projects_root).expanduser().resolve())
170
+ if not Path(projects_root).exists():
171
+ create = click.confirm(f" Directory '{projects_root}' does not exist. Create it?", default=True)
172
+ if create:
173
+ Path(projects_root).mkdir(parents=True)
174
+ click.echo(f" Created: {projects_root}")
175
+ else:
176
+ click.echo(" Aborted.")
177
+ sys.exit(1)
178
+
179
+ # ── 5. Default visibility ───────────────────────────────────────────────
180
+ visibility = click.prompt(
181
+ " Default repo visibility for new GitHub repos",
182
+ type=click.Choice(["private", "public"]),
183
+ default="private",
184
+ )
185
+
186
+ click.echo()
187
+
188
+ # ── 6. SSH key setup ────────────────────────────────────────────────────
189
+ click.echo(click.style(" Setting up SSH key for GitHub...", bold=True))
190
+
191
+ if not setup.ssh_key_exists():
192
+ click.echo(" No SSH key found — generating one...")
193
+ if setup.generate_ssh_key():
194
+ click.echo(" ✓ SSH key generated")
195
+ else:
196
+ click.echo(click.style(" ✗ Failed to generate SSH key.", fg="red"))
197
+ sys.exit(1)
198
+ else:
199
+ click.echo(" ✓ SSH key already exists")
200
+
201
+ setup.add_github_host_key()
202
+
203
+ if not setup.ssh_key_on_github():
204
+ click.echo(" Uploading SSH key to GitHub...")
205
+ if setup.upload_ssh_key_to_github():
206
+ click.echo(" ✓ SSH key added to GitHub")
207
+ else:
208
+ click.echo(click.style(" ✗ Failed to upload SSH key.", fg="yellow"))
209
+ click.echo(" Run manually: gh ssh-key add ~/.ssh/id_ed25519.pub")
210
+ else:
211
+ click.echo(" ✓ SSH key already on GitHub")
212
+
213
+ click.echo()
214
+
215
+ # ── 7. Write config ─────────────────────────────────────────────────────
216
+ config_path = setup.write_config(github_user, projects_root, visibility)
217
+ click.echo(click.style(f" ✓ Config written to: {config_path}", fg="green"))
218
+
219
+ click.echo()
220
+ click.echo(click.style("=== Setup complete! ===", bold=True))
221
+ click.echo()
222
+ click.echo("Next steps:")
223
+ click.echo(f" github-sync status # see all your projects")
224
+ click.echo(f" github-sync sync --dry-run # preview what will be synced")
225
+ click.echo(f" github-sync sync # sync everything to GitHub")
226
+ click.echo()
227
+
228
+
229
+ # ---------------------------------------------------------------------------
230
+ # Entry point
231
+ # ---------------------------------------------------------------------------
232
+
233
+ def main() -> None:
234
+ cli()
@@ -0,0 +1,35 @@
1
+ from pathlib import Path
2
+ import yaml
3
+
4
+ DEFAULT_CONFIG = Path.home() / ".config" / "github-sync" / "config.yaml"
5
+ FALLBACK_CONFIG = Path(__file__).parent.parent / "config.yaml"
6
+
7
+
8
+ def load(path: Path | None = None) -> dict:
9
+ """Load config from path, or search default locations."""
10
+ candidates = [path, DEFAULT_CONFIG, FALLBACK_CONFIG]
11
+ for p in candidates:
12
+ if p and p.exists():
13
+ with open(p) as f:
14
+ cfg = yaml.safe_load(f)
15
+ _apply_defaults(cfg)
16
+ return cfg
17
+ raise FileNotFoundError(
18
+ f"No config file found. Create one at {DEFAULT_CONFIG} or pass --config."
19
+ )
20
+
21
+
22
+ def _apply_defaults(cfg: dict) -> None:
23
+ cfg.setdefault("schedule_time", "02:00")
24
+ cfg.setdefault("default_visibility", "private")
25
+ cfg.setdefault("auto_commit_message", "chore: auto-sync")
26
+ cfg.setdefault("exclude", [])
27
+ cfg.setdefault("log_file", str(Path.home() / ".local" / "share" / "github-sync" / "github-sync.log"))
28
+ cfg.setdefault("log_level", "INFO")
29
+ # Expand ~ in projects_root
30
+ if "projects_root" in cfg:
31
+ cfg["projects_root"] = str(Path(cfg["projects_root"]).expanduser())
32
+
33
+ for key in ("github_user", "projects_root"):
34
+ if not cfg.get(key):
35
+ raise ValueError(f"Missing required config key: '{key}'")
@@ -0,0 +1,146 @@
1
+ """
2
+ Setup helpers used by `github-sync init`.
3
+ Checks prerequisites, detects credentials, sets up SSH.
4
+ """
5
+
6
+ import shutil
7
+ import subprocess
8
+ from pathlib import Path
9
+
10
+
11
+ # ---------------------------------------------------------------------------
12
+ # Dependency checks
13
+ # ---------------------------------------------------------------------------
14
+
15
+ def check_git() -> tuple[bool, str]:
16
+ """Returns (installed, version_string)."""
17
+ if not shutil.which("git"):
18
+ return False, ""
19
+ result = subprocess.run(["git", "--version"], capture_output=True, text=True)
20
+ return True, result.stdout.strip()
21
+
22
+
23
+ def check_gh() -> tuple[bool, str]:
24
+ """Returns (installed, version_string)."""
25
+ if not shutil.which("gh"):
26
+ return False, ""
27
+ result = subprocess.run(["gh", "--version"], capture_output=True, text=True)
28
+ return True, result.stdout.splitlines()[0].strip()
29
+
30
+
31
+ def gh_authenticated() -> bool:
32
+ result = subprocess.run(["gh", "auth", "status"], capture_output=True, text=True)
33
+ return result.returncode == 0
34
+
35
+
36
+ def get_github_username() -> str | None:
37
+ """Detect the currently authenticated GitHub username."""
38
+ result = subprocess.run(
39
+ ["gh", "api", "user", "--jq", ".login"],
40
+ capture_output=True, text=True,
41
+ )
42
+ if result.returncode == 0:
43
+ return result.stdout.strip()
44
+ return None
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # SSH helpers
49
+ # ---------------------------------------------------------------------------
50
+
51
+ DEFAULT_KEY = Path.home() / ".ssh" / "id_ed25519"
52
+
53
+
54
+ def ssh_key_exists() -> bool:
55
+ return DEFAULT_KEY.exists()
56
+
57
+
58
+ def generate_ssh_key(passphrase: str = "") -> bool:
59
+ result = subprocess.run(
60
+ ["ssh-keygen", "-t", "ed25519", "-C", "github-sync", "-f", str(DEFAULT_KEY), "-N", passphrase],
61
+ capture_output=True, text=True,
62
+ )
63
+ return result.returncode == 0
64
+
65
+
66
+ def add_github_host_key() -> bool:
67
+ result = subprocess.run(
68
+ ["ssh-keyscan", "-t", "ed25519", "github.com"],
69
+ capture_output=True, text=True,
70
+ )
71
+ if result.returncode != 0:
72
+ return False
73
+ known_hosts = Path.home() / ".ssh" / "known_hosts"
74
+ known_hosts.parent.mkdir(mode=0o700, exist_ok=True)
75
+ existing = known_hosts.read_text() if known_hosts.exists() else ""
76
+ if "github.com" not in existing:
77
+ with open(known_hosts, "a") as f:
78
+ f.write(result.stdout)
79
+ return True
80
+
81
+
82
+ def ssh_key_on_github() -> bool:
83
+ """Test if the current SSH key authenticates to GitHub."""
84
+ result = subprocess.run(
85
+ ["ssh", "-T", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "git@github.com"],
86
+ capture_output=True, text=True,
87
+ )
88
+ return "successfully authenticated" in (result.stdout + result.stderr)
89
+
90
+
91
+ def upload_ssh_key_to_github(title: str = "github-sync") -> bool:
92
+ pub_key = Path(str(DEFAULT_KEY) + ".pub")
93
+ if not pub_key.exists():
94
+ return False
95
+ result = subprocess.run(
96
+ ["gh", "ssh-key", "add", str(pub_key), "--title", title],
97
+ capture_output=True, text=True,
98
+ )
99
+ return result.returncode == 0
100
+
101
+
102
+ # ---------------------------------------------------------------------------
103
+ # Config writer
104
+ # ---------------------------------------------------------------------------
105
+
106
+ CONFIG_DIR = Path.home() / ".config" / "github-sync"
107
+ CONFIG_PATH = CONFIG_DIR / "config.yaml"
108
+
109
+ CONFIG_TEMPLATE = """\
110
+ # GitHub Sync Agent — configuration
111
+ # Generated by `github-sync init`
112
+
113
+ github_user: {github_user}
114
+
115
+ # Root folder — every subdirectory here is treated as a project to sync
116
+ projects_root: {projects_root}
117
+
118
+ # Daily schedule time (used by the systemd timer / cron)
119
+ schedule_time: "02:00"
120
+
121
+ # Default visibility for newly created GitHub repos: private | public
122
+ default_visibility: {visibility}
123
+
124
+ # Commit message used for auto-commits
125
+ auto_commit_message: "chore: auto-sync"
126
+
127
+ # Project directories to skip (names only, not full paths)
128
+ exclude: []
129
+
130
+ # Logging
131
+ log_file: {log_file}
132
+ log_level: INFO
133
+ """
134
+
135
+
136
+ def write_config(github_user: str, projects_root: str, visibility: str) -> Path:
137
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
138
+ log_file = str(Path.home() / ".local" / "share" / "github-sync" / "github-sync.log")
139
+ content = CONFIG_TEMPLATE.format(
140
+ github_user=github_user,
141
+ projects_root=projects_root,
142
+ visibility=visibility,
143
+ log_file=log_file,
144
+ )
145
+ CONFIG_PATH.write_text(content)
146
+ return CONFIG_PATH
@@ -0,0 +1,132 @@
1
+ Metadata-Version: 2.4
2
+ Name: github-sync-agent
3
+ Version: 0.1.0
4
+ Summary: Sync all your local projects to GitHub automatically
5
+ Author: yuvalavni
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/yuvalavni/Agents
8
+ Project-URL: Repository, https://github.com/yuvalavni/Agents
9
+ Keywords: github,git,sync,backup,automation,cli
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: POSIX :: Linux
13
+ Classifier: Environment :: Console
14
+ Classifier: Topic :: Software Development :: Version Control :: Git
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: pyyaml>=6.0
18
+ Requires-Dist: click>=8.0
19
+
20
+ # GitHub Sync Agent
21
+
22
+ Automatically syncs every project under `/home/yuval/projects` to GitHub (`github.com/yuvalavni`).
23
+
24
+ For each project it will:
25
+ 1. `git init` if not already a repo
26
+ 2. Create the GitHub repo if it doesn't exist yet
27
+ 3. Ensure the remote uses SSH
28
+ 4. Stage + commit any uncommitted changes
29
+ 5. Push to `origin`
30
+
31
+ Runs daily at **02:00** via a systemd user timer, and on-demand via CLI.
32
+
33
+ ---
34
+
35
+ ## Directory layout
36
+
37
+ ```
38
+ agents/github-sync/
39
+ ├── github_sync_agent.py # Main script
40
+ ├── config.yaml # GitHub user, projects root, schedule, exclusions
41
+ ├── requirements.txt
42
+ ├── install.sh # One-time setup
43
+ ├── systemd/
44
+ │ ├── github-sync.service
45
+ │ └── github-sync.timer
46
+ └── logs/
47
+ └── github-sync.log
48
+ ```
49
+
50
+ ---
51
+
52
+ ## Quick start
53
+
54
+ ```bash
55
+ cd /home/yuval/projects/Agents/agents/github-sync
56
+
57
+ # 1. Install (once)
58
+ bash install.sh
59
+
60
+ # 2. Dry run
61
+ .venv/bin/python github_sync_agent.py --dry-run
62
+
63
+ # 3. Sync for real
64
+ .venv/bin/python github_sync_agent.py
65
+ ```
66
+
67
+ ---
68
+
69
+ ## Configuration (`config.yaml`)
70
+
71
+ | Key | Default | Description |
72
+ |-----|---------|-------------|
73
+ | `github_user` | `yuvalavni` | GitHub username |
74
+ | `projects_root` | `/home/yuval/projects` | Scans all subdirectories here |
75
+ | `schedule_time` | `02:00` | Daily run time |
76
+ | `default_visibility` | `private` | `private` or `public` for new repos |
77
+ | `auto_commit_message` | `chore: auto-sync` | Commit message for auto-commits |
78
+ | `exclude` | `[]` | Directory names to skip |
79
+ | `log_file` | `logs/github-sync.log` | Log file path |
80
+ | `log_level` | `INFO` | `DEBUG` / `INFO` / `WARNING` / `ERROR` |
81
+
82
+ ### Excluding a project
83
+
84
+ ```yaml
85
+ exclude:
86
+ - SomePrivateProject
87
+ - TempExperiment
88
+ ```
89
+
90
+ ---
91
+
92
+ ## CLI reference
93
+
94
+ ```bash
95
+ # Sync all projects
96
+ .venv/bin/python github_sync_agent.py
97
+
98
+ # Sync a single project
99
+ .venv/bin/python github_sync_agent.py --project Agents
100
+
101
+ # Preview without making any changes
102
+ .venv/bin/python github_sync_agent.py --dry-run
103
+
104
+ # Show status of all projects (git, remote, changes, GitHub)
105
+ .venv/bin/python github_sync_agent.py --status
106
+ ```
107
+
108
+ ---
109
+
110
+ ## Systemd
111
+
112
+ ```bash
113
+ # Trigger immediately
114
+ systemctl --user start github-sync.service
115
+
116
+ # Check next scheduled run
117
+ systemctl --user list-timers github-sync.timer
118
+
119
+ # View live logs
120
+ journalctl --user -u github-sync.service -f
121
+
122
+ # Disable daily timer
123
+ systemctl --user disable --now github-sync.timer
124
+ ```
125
+
126
+ ---
127
+
128
+ ## Prerequisites
129
+
130
+ - `git` installed
131
+ - `gh` installed and authenticated (`gh auth status`)
132
+ - SSH key added to GitHub (`gh ssh-key list`)
@@ -0,0 +1,13 @@
1
+ README.md
2
+ pyproject.toml
3
+ github_sync/__init__.py
4
+ github_sync/agent.py
5
+ github_sync/cli.py
6
+ github_sync/config.py
7
+ github_sync/setup.py
8
+ github_sync_agent.egg-info/PKG-INFO
9
+ github_sync_agent.egg-info/SOURCES.txt
10
+ github_sync_agent.egg-info/dependency_links.txt
11
+ github_sync_agent.egg-info/entry_points.txt
12
+ github_sync_agent.egg-info/requires.txt
13
+ github_sync_agent.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ github-sync = github_sync.cli:main
@@ -0,0 +1,2 @@
1
+ pyyaml>=6.0
2
+ click>=8.0
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["setuptools>=42", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "github-sync-agent"
7
+ version = "0.1.0"
8
+ description = "Sync all your local projects to GitHub automatically"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [
13
+ { name = "yuvalavni" }
14
+ ]
15
+ keywords = ["github", "git", "sync", "backup", "automation", "cli"]
16
+ classifiers = [
17
+ "Programming Language :: Python :: 3",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Operating System :: POSIX :: Linux",
20
+ "Environment :: Console",
21
+ "Topic :: Software Development :: Version Control :: Git",
22
+ ]
23
+ dependencies = [
24
+ "pyyaml>=6.0",
25
+ "click>=8.0",
26
+ ]
27
+
28
+ [project.urls]
29
+ Homepage = "https://github.com/yuvalavni/Agents"
30
+ Repository = "https://github.com/yuvalavni/Agents"
31
+
32
+ [project.scripts]
33
+ github-sync = "github_sync.cli:main"
34
+
35
+ [tool.setuptools.packages.find]
36
+ where = ["."]
37
+ include = ["github_sync*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+