piagentsync 0.0.1rc0__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.
- piagentsync/__init__.py +1 -0
- piagentsync/cli.py +272 -0
- piagentsync/config.py +38 -0
- piagentsync/errors.py +33 -0
- piagentsync/manifest.py +30 -0
- piagentsync/models.py +82 -0
- piagentsync/sync.py +116 -0
- piagentsync-0.0.1rc0.dist-info/METADATA +161 -0
- piagentsync-0.0.1rc0.dist-info/RECORD +12 -0
- piagentsync-0.0.1rc0.dist-info/WHEEL +4 -0
- piagentsync-0.0.1rc0.dist-info/entry_points.txt +2 -0
- piagentsync-0.0.1rc0.dist-info/licenses/LICENSE +21 -0
piagentsync/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.0.1-rc"
|
piagentsync/cli.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""CLI entry point for piagentsync."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from pydantic import ValidationError
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .config import Settings, get_settings
|
|
13
|
+
from .errors import ManifestNotFoundError, ManifestParseError, PiAgentSyncError
|
|
14
|
+
from .manifest import parse_manifest
|
|
15
|
+
from .models import ProjectManifest, SyncEntry
|
|
16
|
+
from .sync import collect_entries, collect_global_entries, sync_entries
|
|
17
|
+
|
|
18
|
+
app = typer.Typer()
|
|
19
|
+
console = Console()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@app.callback(invoke_without_command=True)
|
|
23
|
+
def main(
|
|
24
|
+
ctx: typer.Context,
|
|
25
|
+
version: bool = typer.Option(False, "--version", help="Print version and exit"),
|
|
26
|
+
) -> None:
|
|
27
|
+
if version:
|
|
28
|
+
console.print(f"piagentsync {__version__}")
|
|
29
|
+
raise typer.Exit(0)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@app.command()
|
|
33
|
+
def pull(
|
|
34
|
+
project: str | None = typer.Argument(None, help="Project name"),
|
|
35
|
+
dry_run: bool = typer.Option(
|
|
36
|
+
False, "--dry-run", "--no-dry-run", help="Preview changes without writing"
|
|
37
|
+
),
|
|
38
|
+
global_sync: bool = typer.Option(
|
|
39
|
+
False, "--global", "--no-global", help="Also sync global agents"
|
|
40
|
+
),
|
|
41
|
+
all_projects: bool = typer.Option(
|
|
42
|
+
False, "--all", "--no-all", help="Sync all discovered projects"
|
|
43
|
+
),
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Sync projects from vault to workspace."""
|
|
46
|
+
if project is None and not all_projects:
|
|
47
|
+
console.print("[red]Error:[/] Must specify a project name or use --all flag")
|
|
48
|
+
raise typer.Exit(1)
|
|
49
|
+
if project is not None and all_projects:
|
|
50
|
+
console.print("[red]Error:[/] Cannot specify both project and --all")
|
|
51
|
+
raise typer.Exit(1)
|
|
52
|
+
|
|
53
|
+
settings = get_settings()
|
|
54
|
+
if not settings.vault_path.exists():
|
|
55
|
+
console.print(f"[red]Vault not found:[/] {settings.vault_path}")
|
|
56
|
+
raise typer.Exit(1)
|
|
57
|
+
|
|
58
|
+
if all_projects:
|
|
59
|
+
projects_dir = settings.vault_path / "projects"
|
|
60
|
+
if not projects_dir.exists():
|
|
61
|
+
console.print("[yellow]No projects found (directory missing)[/]")
|
|
62
|
+
raise typer.Exit(0)
|
|
63
|
+
project_names = [p.name for p in projects_dir.iterdir() if p.is_dir()]
|
|
64
|
+
any_errors = False
|
|
65
|
+
for proj_name in project_names:
|
|
66
|
+
try:
|
|
67
|
+
_sync_single_project(proj_name, dry_run, global_sync, settings)
|
|
68
|
+
except typer.Exit:
|
|
69
|
+
any_errors = True
|
|
70
|
+
if any_errors:
|
|
71
|
+
raise typer.Exit(1)
|
|
72
|
+
else:
|
|
73
|
+
_sync_single_project(project, dry_run, global_sync, settings)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _sync_single_project(
|
|
77
|
+
project: str, dry_run: bool, global_sync: bool, settings: Settings
|
|
78
|
+
) -> None:
|
|
79
|
+
manifest_path = settings.vault_path / "projects" / project / "AGENTS.md"
|
|
80
|
+
try:
|
|
81
|
+
manifest = parse_manifest(manifest_path)
|
|
82
|
+
except ManifestNotFoundError:
|
|
83
|
+
console.print(f"[red]Manifest not found for project:[/] {project}")
|
|
84
|
+
raise typer.Exit(1)
|
|
85
|
+
except ManifestParseError as e:
|
|
86
|
+
console.print(f"[red]Failed to parse manifest for project {project}:[/] {e}")
|
|
87
|
+
raise typer.Exit(1)
|
|
88
|
+
|
|
89
|
+
entries = collect_entries(manifest, settings)
|
|
90
|
+
if global_sync:
|
|
91
|
+
try:
|
|
92
|
+
global_entries = collect_global_entries(settings)
|
|
93
|
+
entries.extend(global_entries)
|
|
94
|
+
except Exception as e:
|
|
95
|
+
console.print(f"[yellow]Warning:[/] Failed to collect global agents: {e}")
|
|
96
|
+
|
|
97
|
+
result = sync_entries(entries, dry_run=dry_run)
|
|
98
|
+
if dry_run:
|
|
99
|
+
console.print("[yellow]Dry run enabled; no files were written.[/]")
|
|
100
|
+
_print_sync_result(result)
|
|
101
|
+
if result.has_errors:
|
|
102
|
+
raise typer.Exit(1)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _print_sync_result(result) -> None:
|
|
106
|
+
table = Table()
|
|
107
|
+
table.add_column("Name")
|
|
108
|
+
table.add_column("Kind")
|
|
109
|
+
table.add_column("Status")
|
|
110
|
+
for entry in result.written:
|
|
111
|
+
table.add_row(entry.name, entry.kind, "written")
|
|
112
|
+
for entry in result.skipped:
|
|
113
|
+
table.add_row(entry.name, entry.kind, "skipped")
|
|
114
|
+
for entry, error_msg in result.errors:
|
|
115
|
+
table.add_row(entry.name, entry.kind, f"error: {error_msg}")
|
|
116
|
+
console.print(table)
|
|
117
|
+
console.print(result.summary_line)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@app.command()
|
|
121
|
+
def status(
|
|
122
|
+
project: str | None = typer.Argument(None, help="Project name (optional)"),
|
|
123
|
+
) -> None:
|
|
124
|
+
"""Show sync status without writing."""
|
|
125
|
+
settings = get_settings()
|
|
126
|
+
if not settings.vault_path.exists():
|
|
127
|
+
console.print(f"[red]Vault not found:[/] {settings.vault_path}")
|
|
128
|
+
raise typer.Exit(1)
|
|
129
|
+
|
|
130
|
+
if project is None:
|
|
131
|
+
projects_dir = settings.vault_path / "projects"
|
|
132
|
+
if not projects_dir.exists():
|
|
133
|
+
console.print("[yellow]No projects found[/]")
|
|
134
|
+
return
|
|
135
|
+
project_names = [p.name for p in projects_dir.iterdir() if p.is_dir()]
|
|
136
|
+
all_entries: list[tuple[str, SyncEntry]] = []
|
|
137
|
+
for proj_name in project_names:
|
|
138
|
+
try:
|
|
139
|
+
manifest = parse_manifest(projects_dir / proj_name / "AGENTS.md")
|
|
140
|
+
entries = collect_entries(manifest, settings)
|
|
141
|
+
all_entries.extend([(proj_name, e) for e in entries])
|
|
142
|
+
except PiAgentSyncError as e:
|
|
143
|
+
console.print(f"[red]Error loading project {proj_name}:[/] {e}")
|
|
144
|
+
_display_status(all_entries)
|
|
145
|
+
else:
|
|
146
|
+
manifest_path = settings.vault_path / "projects" / project / "AGENTS.md"
|
|
147
|
+
try:
|
|
148
|
+
manifest = parse_manifest(manifest_path)
|
|
149
|
+
except ManifestNotFoundError:
|
|
150
|
+
console.print(f"[red]Manifest not found for project:[/] {project}")
|
|
151
|
+
raise typer.Exit(1)
|
|
152
|
+
except ManifestParseError as e:
|
|
153
|
+
console.print(f"[red]Failed to parse manifest:[/] {e}")
|
|
154
|
+
raise typer.Exit(1)
|
|
155
|
+
entries = collect_entries(manifest, settings)
|
|
156
|
+
_display_status([(project, e) for e in entries])
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _display_status(entries_with_project: list[tuple[str, SyncEntry]]) -> None:
|
|
160
|
+
table = Table()
|
|
161
|
+
table.add_column("Project")
|
|
162
|
+
table.add_column("Name")
|
|
163
|
+
table.add_column("Kind")
|
|
164
|
+
table.add_column("Local")
|
|
165
|
+
table.add_column("Vault")
|
|
166
|
+
table.add_column("State")
|
|
167
|
+
for proj, entry in entries_with_project:
|
|
168
|
+
# Determine state and local presence
|
|
169
|
+
dest = entry.destination
|
|
170
|
+
if dest.exists():
|
|
171
|
+
try:
|
|
172
|
+
with open(entry.source, "rb") as f:
|
|
173
|
+
src_hash = hashlib.sha256(f.read()).hexdigest()
|
|
174
|
+
with open(dest, "rb") as f:
|
|
175
|
+
dst_hash = hashlib.sha256(f.read()).hexdigest()
|
|
176
|
+
if src_hash == dst_hash:
|
|
177
|
+
state = "in sync"
|
|
178
|
+
else:
|
|
179
|
+
state = "differs"
|
|
180
|
+
local_desc = "present"
|
|
181
|
+
except OSError as e:
|
|
182
|
+
state = f"error: {e}"
|
|
183
|
+
local_desc = "present?"
|
|
184
|
+
else:
|
|
185
|
+
state = "needs pull"
|
|
186
|
+
local_desc = "missing"
|
|
187
|
+
vault_desc = "present"
|
|
188
|
+
table.add_row(proj, entry.name, entry.kind, local_desc, vault_desc, state)
|
|
189
|
+
console.print(table)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@app.command()
|
|
193
|
+
def init(
|
|
194
|
+
project: str = typer.Argument(..., help="Project slug"),
|
|
195
|
+
workspace: Path = typer.Option(
|
|
196
|
+
..., "--workspace", "-w", help="Workspace directory"
|
|
197
|
+
),
|
|
198
|
+
notion_board_id: str | None = typer.Option(
|
|
199
|
+
None, "--notion-board-id", help="Notion board ID"
|
|
200
|
+
),
|
|
201
|
+
notion_project_filter: str | None = typer.Option(
|
|
202
|
+
None,
|
|
203
|
+
"--notion-project-filter",
|
|
204
|
+
help="Notion project filter (defaults to project slug)",
|
|
205
|
+
),
|
|
206
|
+
) -> None:
|
|
207
|
+
"""Scaffold a new project in the vault."""
|
|
208
|
+
# Validate project slug early using ProjectManifest
|
|
209
|
+
try:
|
|
210
|
+
manifest = ProjectManifest(
|
|
211
|
+
project=project,
|
|
212
|
+
workspace=workspace,
|
|
213
|
+
notion_board_id=notion_board_id,
|
|
214
|
+
notion_project_filter=notion_project_filter or project,
|
|
215
|
+
)
|
|
216
|
+
except ValidationError as e:
|
|
217
|
+
console.print(f"[red]Invalid project slug:[/] {e}")
|
|
218
|
+
raise typer.Exit(1)
|
|
219
|
+
|
|
220
|
+
settings = get_settings()
|
|
221
|
+
project_dir = settings.vault_path / "projects" / project
|
|
222
|
+
if project_dir.exists():
|
|
223
|
+
console.print(f"[red]Project directory already exists:[/] {project_dir}")
|
|
224
|
+
raise typer.Exit(1)
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
project_dir.mkdir(parents=True, exist_ok=False)
|
|
228
|
+
(project_dir / "agents").mkdir()
|
|
229
|
+
(project_dir / "skills").mkdir()
|
|
230
|
+
(project_dir / "context.md").write_text("")
|
|
231
|
+
(project_dir / "decisions.md").write_text("")
|
|
232
|
+
except OSError as e:
|
|
233
|
+
console.print(f"[red]Failed to create project directories:[/] {e}")
|
|
234
|
+
raise typer.Exit(1)
|
|
235
|
+
|
|
236
|
+
# Build AGENTS.md content
|
|
237
|
+
frontmatter = [
|
|
238
|
+
"---",
|
|
239
|
+
f"project: {manifest.project}",
|
|
240
|
+
f"workspace: {workspace}",
|
|
241
|
+
]
|
|
242
|
+
if manifest.notion_board_id:
|
|
243
|
+
frontmatter.append(f"notion_board_id: {manifest.notion_board_id}")
|
|
244
|
+
if manifest.notion_project_filter:
|
|
245
|
+
frontmatter.append(f"notion_project_filter: {manifest.notion_project_filter}")
|
|
246
|
+
frontmatter.append("---")
|
|
247
|
+
frontmatter_body = "\n".join(frontmatter)
|
|
248
|
+
|
|
249
|
+
body = f"""# {project} — agent routing
|
|
250
|
+
|
|
251
|
+
## Stack
|
|
252
|
+
|
|
253
|
+
_Fill in the tech stack._
|
|
254
|
+
|
|
255
|
+
## Agent routing
|
|
256
|
+
|
|
257
|
+
| Task | Agent |
|
|
258
|
+
|------|-------|
|
|
259
|
+
| _example_ | _example-agent_ |
|
|
260
|
+
"""
|
|
261
|
+
agents_md_content = frontmatter_body + "\n\n" + body
|
|
262
|
+
try:
|
|
263
|
+
(project_dir / "AGENTS.md").write_text(agents_md_content)
|
|
264
|
+
# Add .gitkeep files
|
|
265
|
+
(project_dir / "agents" / ".gitkeep").write_text("")
|
|
266
|
+
(project_dir / "skills" / ".gitkeep").write_text("")
|
|
267
|
+
except OSError as e:
|
|
268
|
+
console.print(f"[red]Failed to write manifest files:[/] {e}")
|
|
269
|
+
raise typer.Exit(1)
|
|
270
|
+
|
|
271
|
+
console.print(f"[green]Project initialized:[/] {project}")
|
|
272
|
+
console.print(f"Next step: piagentsync pull {project}")
|
piagentsync/config.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Configuration management for piagentsync."""
|
|
2
|
+
|
|
3
|
+
from functools import lru_cache
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from pydantic import Field, field_validator
|
|
7
|
+
from pydantic_settings import BaseSettings
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Settings(BaseSettings):
|
|
11
|
+
"""Application settings loaded from environment variables."""
|
|
12
|
+
|
|
13
|
+
vault_path: Path = Field(
|
|
14
|
+
default=Path("~/vault"), description="Path to Obsidian vault"
|
|
15
|
+
)
|
|
16
|
+
global_opencode_path: Path = Field(
|
|
17
|
+
default=Path("~/.config/opencode"),
|
|
18
|
+
description="Path to global OpenCode config",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
model_config = {
|
|
22
|
+
"env_prefix": "PIAGENTSYNC_",
|
|
23
|
+
"env_file": ".env",
|
|
24
|
+
"env_file_encoding": "utf-8",
|
|
25
|
+
"case_sensitive": False,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@field_validator("vault_path", "global_opencode_path", mode="after")
|
|
29
|
+
@classmethod
|
|
30
|
+
def expand_paths(cls, v: Path) -> Path:
|
|
31
|
+
"""Expand user home and resolve to absolute path."""
|
|
32
|
+
return v.expanduser().resolve()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@lru_cache(maxsize=1)
|
|
36
|
+
def get_settings() -> Settings:
|
|
37
|
+
"""Return a cached Settings instance."""
|
|
38
|
+
return Settings()
|
piagentsync/errors.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Custom exception hierarchy for piagentsync."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class PiAgentSyncError(Exception):
|
|
5
|
+
"""Base exception for all piagentsync errors."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, message: str) -> None:
|
|
8
|
+
super().__init__(message)
|
|
9
|
+
self.message = message
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ManifestNotFoundError(PiAgentSyncError):
|
|
13
|
+
"""Raised when an AGENTS.md manifest file does not exist."""
|
|
14
|
+
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ManifestParseError(PiAgentSyncError):
|
|
19
|
+
"""Raised when manifest parsing fails (missing or malformed frontmatter)."""
|
|
20
|
+
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class VaultNotFoundError(PiAgentSyncError):
|
|
25
|
+
"""Raised when the vault directory does not exist."""
|
|
26
|
+
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SyncError(PiAgentSyncError):
|
|
31
|
+
"""Raised for general sync failures (e.g., file system errors)."""
|
|
32
|
+
|
|
33
|
+
pass
|
piagentsync/manifest.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Manifest parser using python-frontmatter."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import frontmatter
|
|
6
|
+
|
|
7
|
+
from .errors import ManifestNotFoundError, ManifestParseError
|
|
8
|
+
from .models import ProjectManifest
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def parse_manifest(path: Path) -> ProjectManifest:
|
|
12
|
+
"""Parse YAML frontmatter from an AGENTS.md file.
|
|
13
|
+
|
|
14
|
+
Raises:
|
|
15
|
+
ManifestNotFoundError: If the file does not exist.
|
|
16
|
+
ManifestParseError: If frontmatter is missing or malformed.
|
|
17
|
+
pydantic.ValidationError: If required fields fail validation.
|
|
18
|
+
"""
|
|
19
|
+
if not path.exists():
|
|
20
|
+
raise ManifestNotFoundError(f"Manifest file not found: {path}")
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
post = frontmatter.load(str(path))
|
|
24
|
+
except Exception as e:
|
|
25
|
+
raise ManifestParseError(f"Failed to parse frontmatter: {e}") from e
|
|
26
|
+
|
|
27
|
+
if not post.metadata:
|
|
28
|
+
raise ManifestParseError("No frontmatter found")
|
|
29
|
+
|
|
30
|
+
return ProjectManifest(**post.metadata)
|
piagentsync/models.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Data models for piagentsync."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class EntryKind:
|
|
9
|
+
"""Enum-like class for sync entry kinds."""
|
|
10
|
+
|
|
11
|
+
AGENT = "agent"
|
|
12
|
+
SKILL = "skill"
|
|
13
|
+
CONTEXT_FILE = "context_file"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ProjectManifest(BaseModel):
|
|
17
|
+
"""Manifest parsed from AGENTS.md frontmatter."""
|
|
18
|
+
|
|
19
|
+
model_config = ConfigDict(frozen=True)
|
|
20
|
+
|
|
21
|
+
project: str = Field(..., min_length=1, description="Project slug")
|
|
22
|
+
workspace: Path = Field(..., description="Workspace directory path")
|
|
23
|
+
notion_board_id: str | None = Field(None, description="Optional Notion DB ID")
|
|
24
|
+
notion_project_filter: str | None = Field(
|
|
25
|
+
None, description="Optional Notion project filter"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
@field_validator("project")
|
|
29
|
+
@classmethod
|
|
30
|
+
def validate_project_slug(cls, v: str) -> str:
|
|
31
|
+
"""Project slug must be lowercase alphanumeric with hyphens."""
|
|
32
|
+
import re
|
|
33
|
+
|
|
34
|
+
if not re.match(r"^[a-z0-9-]+$", v):
|
|
35
|
+
raise ValueError("project must be lowercase letters, numbers, and hyphens")
|
|
36
|
+
return v
|
|
37
|
+
|
|
38
|
+
@field_validator("workspace", mode="after")
|
|
39
|
+
@classmethod
|
|
40
|
+
def expand_workspace(cls, v: Path) -> Path:
|
|
41
|
+
"""Expand and resolve workspace path."""
|
|
42
|
+
return v.expanduser().resolve()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SyncEntry(BaseModel):
|
|
46
|
+
"""Represents a single file to sync."""
|
|
47
|
+
|
|
48
|
+
model_config = ConfigDict(frozen=True)
|
|
49
|
+
|
|
50
|
+
name: str = Field(..., description="Filename stem (without extension)")
|
|
51
|
+
source: Path = Field(..., description="Absolute source path in vault")
|
|
52
|
+
destination: Path = Field(..., description="Absolute destination path in workspace")
|
|
53
|
+
kind: str = Field(..., description="Entry kind: agent, skill, or context_file")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class SyncResult(BaseModel):
|
|
57
|
+
"""Result of a sync operation."""
|
|
58
|
+
|
|
59
|
+
model_config = ConfigDict(frozen=True)
|
|
60
|
+
|
|
61
|
+
written: list[SyncEntry] = Field(default_factory=list, description="Files written")
|
|
62
|
+
skipped: list[SyncEntry] = Field(
|
|
63
|
+
default_factory=list, description="Files skipped (unchanged)"
|
|
64
|
+
)
|
|
65
|
+
errors: list[tuple[SyncEntry, str]] = Field(
|
|
66
|
+
default_factory=list, description="(entry, error message) pairs"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def total(self) -> int:
|
|
71
|
+
"""Total entries processed (written + skipped + errors)."""
|
|
72
|
+
return len(self.written) + len(self.skipped) + len(self.errors)
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def has_errors(self) -> bool:
|
|
76
|
+
"""True if any errors occurred during sync."""
|
|
77
|
+
return len(self.errors) > 0
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def summary_line(self) -> str:
|
|
81
|
+
"""One-line summary: 'N written · N skipped · N errors'."""
|
|
82
|
+
return f"{len(self.written)} written · {len(self.skipped)} skipped · {len(self.errors)} errors"
|
piagentsync/sync.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Core synchronization logic."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
|
|
5
|
+
from .config import Settings
|
|
6
|
+
from .models import EntryKind, ProjectManifest, SyncEntry, SyncResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def collect_entries(manifest: ProjectManifest, settings: Settings) -> list[SyncEntry]:
|
|
10
|
+
"""Collect all syncable entries for a project."""
|
|
11
|
+
vault = settings.vault_path
|
|
12
|
+
project_dir = vault / "projects" / manifest.project
|
|
13
|
+
entries: list[SyncEntry] = []
|
|
14
|
+
|
|
15
|
+
# Agents
|
|
16
|
+
agents_dir = project_dir / "agents"
|
|
17
|
+
if agents_dir.is_dir():
|
|
18
|
+
for file in agents_dir.iterdir():
|
|
19
|
+
if file.is_file() and file.suffix.lower() == ".md":
|
|
20
|
+
dest = manifest.workspace / ".opencode" / "agents" / file.name
|
|
21
|
+
entries.append(
|
|
22
|
+
SyncEntry(
|
|
23
|
+
name=file.stem,
|
|
24
|
+
source=file.resolve(),
|
|
25
|
+
destination=dest,
|
|
26
|
+
kind=EntryKind.AGENT,
|
|
27
|
+
)
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# Skills
|
|
31
|
+
skills_dir = project_dir / "skills"
|
|
32
|
+
if skills_dir.is_dir():
|
|
33
|
+
for file in skills_dir.iterdir():
|
|
34
|
+
if file.is_file() and file.suffix.lower() == ".md":
|
|
35
|
+
dest = manifest.workspace / ".opencode" / "skills" / file.name
|
|
36
|
+
entries.append(
|
|
37
|
+
SyncEntry(
|
|
38
|
+
name=file.stem,
|
|
39
|
+
source=file.resolve(),
|
|
40
|
+
destination=dest,
|
|
41
|
+
kind=EntryKind.SKILL,
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# AGENTS.md (context file)
|
|
46
|
+
agents_md = project_dir / "AGENTS.md"
|
|
47
|
+
if agents_md.is_file():
|
|
48
|
+
dest = manifest.workspace / ".opencode" / "AGENTS.md"
|
|
49
|
+
entries.append(
|
|
50
|
+
SyncEntry(
|
|
51
|
+
name=agents_md.stem,
|
|
52
|
+
source=agents_md.resolve(),
|
|
53
|
+
destination=dest,
|
|
54
|
+
kind=EntryKind.CONTEXT_FILE,
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
return entries
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def collect_global_entries(settings: Settings) -> list[SyncEntry]:
|
|
62
|
+
"""Collect all global agent entries."""
|
|
63
|
+
vault = settings.vault_path
|
|
64
|
+
global_dir = vault / "agents" / "global"
|
|
65
|
+
entries: list[SyncEntry] = []
|
|
66
|
+
if global_dir.is_dir():
|
|
67
|
+
dest_dir = settings.global_opencode_path / "agents"
|
|
68
|
+
for file in global_dir.iterdir():
|
|
69
|
+
if file.is_file() and file.suffix.lower() == ".md":
|
|
70
|
+
dest = dest_dir / file.name
|
|
71
|
+
entries.append(
|
|
72
|
+
SyncEntry(
|
|
73
|
+
name=file.stem,
|
|
74
|
+
source=file.resolve(),
|
|
75
|
+
destination=dest,
|
|
76
|
+
kind=EntryKind.AGENT,
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
return entries
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def sync_entries(entries: list[SyncEntry], dry_run: bool = False) -> SyncResult:
|
|
83
|
+
"""Synchronize entries from source to destination."""
|
|
84
|
+
written: list[SyncEntry] = []
|
|
85
|
+
skipped: list[SyncEntry] = []
|
|
86
|
+
errors: list[tuple[SyncEntry, str]] = []
|
|
87
|
+
|
|
88
|
+
for entry in entries:
|
|
89
|
+
try:
|
|
90
|
+
if not entry.source.exists():
|
|
91
|
+
raise FileNotFoundError(f"Source file not found: {entry.source}")
|
|
92
|
+
|
|
93
|
+
with open(entry.source, "rb") as f:
|
|
94
|
+
src_content = f.read()
|
|
95
|
+
src_hash = hashlib.sha256(src_content).hexdigest()
|
|
96
|
+
|
|
97
|
+
dest = entry.destination
|
|
98
|
+
if dest.exists():
|
|
99
|
+
with open(dest, "rb") as f:
|
|
100
|
+
dest_content = f.read()
|
|
101
|
+
dest_hash = hashlib.sha256(dest_content).hexdigest()
|
|
102
|
+
if src_hash == dest_hash:
|
|
103
|
+
skipped.append(entry)
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
if not dry_run:
|
|
107
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
108
|
+
with open(dest, "wb") as f:
|
|
109
|
+
f.write(src_content)
|
|
110
|
+
|
|
111
|
+
written.append(entry)
|
|
112
|
+
|
|
113
|
+
except OSError as e:
|
|
114
|
+
errors.append((entry, str(e)))
|
|
115
|
+
|
|
116
|
+
return SyncResult(written=written, skipped=skipped, errors=errors)
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: piagentsync
|
|
3
|
+
Version: 0.0.1rc0
|
|
4
|
+
Summary: Sync OpenCode agents and skills from an Obsidian vault to workspace directories
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.13
|
|
8
|
+
Requires-Dist: pydantic-settings>=2.3
|
|
9
|
+
Requires-Dist: pydantic>=2.7
|
|
10
|
+
Requires-Dist: python-frontmatter>=1.1
|
|
11
|
+
Requires-Dist: rich>=13
|
|
12
|
+
Requires-Dist: typer>=0.12
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# piagentsync
|
|
16
|
+
|
|
17
|
+
Sync OpenCode agents and skills from an Obsidian vault to workspace directories.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
uv add piagentsync
|
|
23
|
+
# or
|
|
24
|
+
pip install piagentsync
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
1. Initialize a new project in your vault:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
piagentsync init myproject --workspace ~/workspace/myproject
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
2. Pull the synced files to your workspace:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
piagentsync pull myproject
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Configuration
|
|
42
|
+
|
|
43
|
+
Environment variables (also configurable via `.env` file):
|
|
44
|
+
|
|
45
|
+
| Variable | Default | Description |
|
|
46
|
+
|----------|---------|-------------|
|
|
47
|
+
| `PIAGENTSYNC_VAULT_PATH` | `~/vault` | Path to Obsidian vault |
|
|
48
|
+
| `PIAGENTSYNC_GLOBAL_OPENCODE_PATH` | `~/.config/opencode` | Path to global OpenCode config |
|
|
49
|
+
|
|
50
|
+
## CLI reference
|
|
51
|
+
|
|
52
|
+
### `piagentsync pull <project>`
|
|
53
|
+
|
|
54
|
+
Sync a single project from vault to workspace.
|
|
55
|
+
|
|
56
|
+
Options:
|
|
57
|
+
- `--dry-run` / `--no-dry-run` — preview changes without writing
|
|
58
|
+
- `--global` / `--no-global` — also sync global agents
|
|
59
|
+
- `--all` — sync all discovered projects
|
|
60
|
+
|
|
61
|
+
### `piagentsync status [project]`
|
|
62
|
+
|
|
63
|
+
Show diff between vault and workspace.
|
|
64
|
+
|
|
65
|
+
Options:
|
|
66
|
+
- `--all` — show status for all projects (default when no project given)
|
|
67
|
+
|
|
68
|
+
### `piagentsync init <project>`
|
|
69
|
+
|
|
70
|
+
Scaffold a new project in the vault.
|
|
71
|
+
|
|
72
|
+
Options:
|
|
73
|
+
- `--workspace PATH` — required, workspace directory
|
|
74
|
+
- `--notion-board-id TEXT` — optional Notion DB ID
|
|
75
|
+
- `--notion-project-filter TEXT` — optional Notion project filter (defaults to project slug)
|
|
76
|
+
|
|
77
|
+
### `--version`
|
|
78
|
+
|
|
79
|
+
Print version and exit.
|
|
80
|
+
|
|
81
|
+
## AGENTS.md manifest format
|
|
82
|
+
|
|
83
|
+
Each project must have an `AGENTS.md` file in its root with YAML frontmatter:
|
|
84
|
+
|
|
85
|
+
```yaml
|
|
86
|
+
---
|
|
87
|
+
project: myproject
|
|
88
|
+
workspace: ~/workspace/myproject
|
|
89
|
+
notion_board_id: 3305f9479a8d8055b3c3e86a9006cf91
|
|
90
|
+
notion_project_filter: myproject
|
|
91
|
+
---
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The body below the frontmatter is the OpenCode routing table.
|
|
95
|
+
|
|
96
|
+
## Expected vault structure
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
vault/
|
|
100
|
+
├── agents/
|
|
101
|
+
│ └── global/
|
|
102
|
+
│ └── *.md
|
|
103
|
+
└── projects/
|
|
104
|
+
└── {project}/
|
|
105
|
+
├── AGENTS.md # manifest with frontmatter
|
|
106
|
+
├── context.md # optional context file
|
|
107
|
+
├── decisions.md # optional decisions log
|
|
108
|
+
├── agents/
|
|
109
|
+
│ └── *.md
|
|
110
|
+
└── skills/
|
|
111
|
+
└── *.md
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Contributing
|
|
115
|
+
|
|
116
|
+
Development setup:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
uv sync
|
|
120
|
+
uv run pytest
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Lint and format:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
uv run ruff check src/ tests/
|
|
127
|
+
uv run ruff format src/ tests/
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
All commits follow [Conventional Commits](https://www.conventionalcommits.org/).
|
|
131
|
+
|
|
132
|
+
### Publishing (maintainers)
|
|
133
|
+
|
|
134
|
+
This repository uses GitHub Actions for CI and automated releases.
|
|
135
|
+
|
|
136
|
+
#### One-time PyPI setup (trusted publisher)
|
|
137
|
+
|
|
138
|
+
1. Enable OIDC on PyPI for the repository:
|
|
139
|
+
- Publisher: GitHub Actions
|
|
140
|
+
- Repository owner: `Piwero`
|
|
141
|
+
- Repository name: `piagentsync`
|
|
142
|
+
- Workflow filename: `publish.yml`
|
|
143
|
+
- Environment name: (leave blank)
|
|
144
|
+
|
|
145
|
+
2. Push a tag to trigger the `tag.yml` workflow to bump version and create a git tag.
|
|
146
|
+
|
|
147
|
+
3. After the tag workflow completes, the `publish.yml` workflow will automatically build and publish the package to PyPI.
|
|
148
|
+
|
|
149
|
+
#### Release process
|
|
150
|
+
|
|
151
|
+
```text
|
|
152
|
+
git push → ci.yml passes
|
|
153
|
+
→ trigger tag.yml (choose MAJOR/MINOR/PATCH/RC)
|
|
154
|
+
→ tests re-run → version bumped → tag pushed
|
|
155
|
+
→ trigger publish.yml
|
|
156
|
+
→ tests re-run → built → published to PyPI
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## License
|
|
160
|
+
|
|
161
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
piagentsync/__init__.py,sha256=eAmeXLaXDnlioXR5SPcKxvWxKEyvC8SHO7PgPLtfNQ8,25
|
|
2
|
+
piagentsync/cli.py,sha256=QJhv0_dwGy5_kx2uNmaSIRsuIxZm9EvQ6Jpi2vjswdM,9618
|
|
3
|
+
piagentsync/config.py,sha256=yaVX-brXMcGx0UhwXyzx2xsIMvLg79nfzwPJ2m5Bf0Y,1067
|
|
4
|
+
piagentsync/errors.py,sha256=kaPaBzlCAyDh86U9Hn7KhvpeDUmlTdjCM9G6Q394_FA,745
|
|
5
|
+
piagentsync/manifest.py,sha256=5nXqZzhNoj4n0SqrTYiDocaTNQi9uandqCDhjlLR5-g,902
|
|
6
|
+
piagentsync/models.py,sha256=kH8BdszN7JCJ-EQPhMQSTnnsSWPvofJiuG8CbdjyXR0,2727
|
|
7
|
+
piagentsync/sync.py,sha256=DAOfkmgBAOJ_JYXjjMKqFnCd7jSmvTy19y9qFLIbszQ,3943
|
|
8
|
+
piagentsync-0.0.1rc0.dist-info/METADATA,sha256=ZcS2PtoCivBIT2OleWDw5LpgLcqo_G2YDBooLjUQBF4,3746
|
|
9
|
+
piagentsync-0.0.1rc0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
10
|
+
piagentsync-0.0.1rc0.dist-info/entry_points.txt,sha256=bYGWj8MEC3oKRodPutyXVKH59ZhHa35AHol3dGC5neM,52
|
|
11
|
+
piagentsync-0.0.1rc0.dist-info/licenses/LICENSE,sha256=DcZJcxG6YDw4d0E0UTfJIjfzCy1g8txOmxr5fVY9en0,1063
|
|
12
|
+
piagentsync-0.0.1rc0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Piwero
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|