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.
@@ -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
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ piagentsync = piagentsync.cli:app
@@ -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.