loomwork 0.1.0__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.
- loomwork/__init__.py +4 -0
- loomwork/agents/__init__.py +15 -0
- loomwork/agents/base.py +153 -0
- loomwork/agents/claude_code.py +110 -0
- loomwork/agents/opencode.py +20 -0
- loomwork/cli.py +103 -0
- loomwork/declaration.py +120 -0
- loomwork/git.py +66 -0
- loomwork/installation.py +172 -0
- loomwork/loom.py +353 -0
- loomwork/output.py +51 -0
- loomwork/paths.py +68 -0
- loomwork/prompts.py +83 -0
- loomwork/questions.py +115 -0
- loomwork/registry.py +104 -0
- loomwork/sources.py +193 -0
- loomwork-0.1.0.dist-info/METADATA +200 -0
- loomwork-0.1.0.dist-info/RECORD +21 -0
- loomwork-0.1.0.dist-info/WHEEL +4 -0
- loomwork-0.1.0.dist-info/entry_points.txt +2 -0
- loomwork-0.1.0.dist-info/licenses/LICENSE +21 -0
loomwork/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from loomwork.agents.base import Agent
|
|
6
|
+
from loomwork.agents.claude_code import ClaudeCode
|
|
7
|
+
from loomwork.agents.opencode import OpenCode
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def roster(claude_config_dir: Path) -> tuple[Agent, ...]:
|
|
11
|
+
"""Every supported agent, in the order a machine converges them — Claude in the config dir of this profile."""
|
|
12
|
+
return (ClaudeCode(claude_config_dir), OpenCode())
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
__all__ = ["Agent", "ClaudeCode", "OpenCode", "roster"]
|
loomwork/agents/base.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import ClassVar
|
|
7
|
+
|
|
8
|
+
from loomwork import COMMAND_NAME, paths
|
|
9
|
+
from loomwork.installation import Installation
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Agent(ABC):
|
|
13
|
+
"""A supported agent: global rules and skills go into `rules/` and `skills/` in its config dir.
|
|
14
|
+
|
|
15
|
+
An empty project path means the agent reads `.agents/` in the project itself, so nothing is laid for it there.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
NAME: ClassVar[str]
|
|
19
|
+
# what the agent is called on PATH
|
|
20
|
+
COMMAND: ClassVar[str]
|
|
21
|
+
# what the agent calls the file it reads instructions from; empty when it reads none
|
|
22
|
+
AGENTS_MD_NAME: ClassVar[str] = ""
|
|
23
|
+
# the dir in a project it reads rules from, recursively
|
|
24
|
+
PROJECT_RULES_DIR: ClassVar[str] = ""
|
|
25
|
+
# the dir in a project it finds skills in, one level deep
|
|
26
|
+
PROJECT_SKILLS_DIR: ClassVar[str] = ""
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def config_dir(self) -> Path:
|
|
31
|
+
"""Where the agent keeps its own configuration — our presence marker, and the root of its user-level paths."""
|
|
32
|
+
|
|
33
|
+
def bridge(self, installation: Installation) -> tuple[Path, ...]:
|
|
34
|
+
"""Lay every user-level surface this agent names a path for, naming what was laid."""
|
|
35
|
+
return (
|
|
36
|
+
*self._publish_agents_md(installation),
|
|
37
|
+
*self._bridge_global_rules(installation),
|
|
38
|
+
*self._bridge_global_skills(installation),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def unbridge(self, installation: Installation) -> None:
|
|
42
|
+
"""Mirror of bridge(): what is ours goes, anything the user put there stays."""
|
|
43
|
+
installation.clear_owned_in(self.config_dir / "skills")
|
|
44
|
+
rules_dir = self.config_dir / "rules"
|
|
45
|
+
installation.clear_owned_in(rules_dir)
|
|
46
|
+
paths.remove_if_empty(rules_dir)
|
|
47
|
+
if self.AGENTS_MD_NAME:
|
|
48
|
+
published = self.config_dir / self.AGENTS_MD_NAME
|
|
49
|
+
if installation.owned(published):
|
|
50
|
+
published.unlink()
|
|
51
|
+
|
|
52
|
+
def bridge_project(self, installation: Installation, project_dir: Path) -> tuple[Path, ...]:
|
|
53
|
+
"""Point the agent at this project's rules and skills, naming where it reads them from."""
|
|
54
|
+
return (
|
|
55
|
+
*self._bridge_project_rules(installation, project_dir),
|
|
56
|
+
*self._bridge_project_skills(installation, project_dir),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def unbridge_project(self, installation: Installation, project_dir: Path) -> None:
|
|
60
|
+
"""Drop our rule and skill links from the project, and the dirs when they empty out."""
|
|
61
|
+
for relative in (self.PROJECT_RULES_DIR, self.PROJECT_SKILLS_DIR):
|
|
62
|
+
if not relative:
|
|
63
|
+
continue
|
|
64
|
+
linked_dir = project_dir / relative
|
|
65
|
+
self._drop_whole_dir_link(installation, linked_dir)
|
|
66
|
+
installation.clear_owned_in(linked_dir)
|
|
67
|
+
paths.remove_if_empty(linked_dir)
|
|
68
|
+
paths.remove_if_empty(linked_dir.parent)
|
|
69
|
+
|
|
70
|
+
def project_folders(self) -> tuple[str, ...]:
|
|
71
|
+
"""The folders this agent's bridges live in at a project's root — what the project's .gitignore must cover."""
|
|
72
|
+
relatives = (self.PROJECT_RULES_DIR, self.PROJECT_SKILLS_DIR)
|
|
73
|
+
return tuple(sorted({Path(relative).parts[0] for relative in relatives if relative}))
|
|
74
|
+
|
|
75
|
+
def is_present(self) -> bool:
|
|
76
|
+
"""Whether the agent itself is on this machine — its config dir cannot say, since we create that ourselves."""
|
|
77
|
+
return bool(shutil.which(self.COMMAND))
|
|
78
|
+
|
|
79
|
+
def _publish_agents_md(self, installation: Installation) -> tuple[Path, ...]:
|
|
80
|
+
"""Write the source's AGENTS.md under the name this agent reads it by."""
|
|
81
|
+
agents_md = installation.catalog.agents_md
|
|
82
|
+
if not self.AGENTS_MD_NAME or agents_md is None:
|
|
83
|
+
return ()
|
|
84
|
+
published = self.config_dir / self.AGENTS_MD_NAME
|
|
85
|
+
installation.place_file(agents_md, published)
|
|
86
|
+
# a file of the user's own was left alone, so it is not ours to report
|
|
87
|
+
if not installation.owned(published):
|
|
88
|
+
return ()
|
|
89
|
+
return (published,)
|
|
90
|
+
|
|
91
|
+
def _bridge_global_rules(self, installation: Installation) -> tuple[Path, ...]:
|
|
92
|
+
"""Copy what the sources hand every project into the agent's own rules dir."""
|
|
93
|
+
rules_dir = self.config_dir / "rules"
|
|
94
|
+
catalog = installation.catalog
|
|
95
|
+
landed = installation.place(catalog.rule_files(catalog.global_rules), rules_dir)
|
|
96
|
+
# a source that hands out nothing leaves no empty dir behind
|
|
97
|
+
paths.remove_if_empty(rules_dir)
|
|
98
|
+
if not landed:
|
|
99
|
+
return ()
|
|
100
|
+
return (rules_dir,)
|
|
101
|
+
|
|
102
|
+
def _bridge_global_skills(self, installation: Installation) -> tuple[Path, ...]:
|
|
103
|
+
"""Copy each globally wanted skill into the agent's own skills dir."""
|
|
104
|
+
skills_dir = self.config_dir / "skills"
|
|
105
|
+
catalog = installation.catalog
|
|
106
|
+
if not installation.place(catalog.skill_folders(catalog.global_skills), skills_dir):
|
|
107
|
+
return ()
|
|
108
|
+
return (skills_dir,)
|
|
109
|
+
|
|
110
|
+
def _bridge_project_rules(self, installation: Installation, project_dir: Path) -> tuple[Path, ...]:
|
|
111
|
+
"""Link .agents/rules one level down in the agent's rules dir, beside whatever the project keeps there."""
|
|
112
|
+
copied_rules = paths.project_rules_dir(project_dir)
|
|
113
|
+
if not self.PROJECT_RULES_DIR:
|
|
114
|
+
return (copied_rules,)
|
|
115
|
+
rules_dir = project_dir / self.PROJECT_RULES_DIR
|
|
116
|
+
self._drop_whole_dir_link(installation, rules_dir)
|
|
117
|
+
rules_dir.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
rules_link = rules_dir / COMMAND_NAME
|
|
119
|
+
installation.symlink(
|
|
120
|
+
paths.link_target(copied_rules, rules_dir),
|
|
121
|
+
rules_link,
|
|
122
|
+
)
|
|
123
|
+
if not installation.owned(rules_link):
|
|
124
|
+
return ()
|
|
125
|
+
return (rules_link,)
|
|
126
|
+
|
|
127
|
+
def _bridge_project_skills(self, installation: Installation, project_dir: Path) -> tuple[Path, ...]:
|
|
128
|
+
"""One link per skill, since skills are found one level deep and the project's own stay beside ours."""
|
|
129
|
+
skills = paths.project_skills_dir(project_dir)
|
|
130
|
+
if not self.PROJECT_SKILLS_DIR:
|
|
131
|
+
if not skills.is_dir():
|
|
132
|
+
return ()
|
|
133
|
+
return (skills,)
|
|
134
|
+
skills_dir = project_dir / self.PROJECT_SKILLS_DIR
|
|
135
|
+
self._drop_whole_dir_link(installation, skills_dir)
|
|
136
|
+
placed = []
|
|
137
|
+
if skills.is_dir():
|
|
138
|
+
placed = sorted(skill for skill in skills.iterdir() if skill.is_dir())
|
|
139
|
+
if not placed and not skills_dir.is_dir():
|
|
140
|
+
return ()
|
|
141
|
+
installation.place_links(
|
|
142
|
+
{skill.name: paths.link_target(skill, skills_dir) for skill in placed},
|
|
143
|
+
skills_dir,
|
|
144
|
+
)
|
|
145
|
+
paths.remove_if_empty(skills_dir)
|
|
146
|
+
if not placed:
|
|
147
|
+
return ()
|
|
148
|
+
return (skills_dir,)
|
|
149
|
+
|
|
150
|
+
def _drop_whole_dir_link(self, installation: Installation, directory: Path) -> None:
|
|
151
|
+
"""A link of ours standing in for the whole dir would put the links meant for it inside .agents/."""
|
|
152
|
+
if directory.is_symlink() and installation.owned(directory):
|
|
153
|
+
directory.unlink()
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shlex
|
|
5
|
+
import shutil
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import ClassVar
|
|
9
|
+
|
|
10
|
+
from loomwork import COMMAND_NAME, paths
|
|
11
|
+
from loomwork.agents.base import Agent
|
|
12
|
+
from loomwork.declaration import Declaration
|
|
13
|
+
from loomwork.installation import Installation
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ClaudeCode(Agent):
|
|
17
|
+
"""Mechanical loader and the host: its engine loads whatever files exist, so projects get wired — and its SessionStart hook is what runs sync for every agent."""
|
|
18
|
+
|
|
19
|
+
NAME = "claude"
|
|
20
|
+
|
|
21
|
+
COMMAND = "claude"
|
|
22
|
+
# at user level Claude reads CLAUDE.md only
|
|
23
|
+
AGENTS_MD_NAME = "CLAUDE.md"
|
|
24
|
+
# where Claude keeps its config unless it is launched with CONFIG_DIR_ENV
|
|
25
|
+
DEFAULT_CONFIG_DIR: ClassVar[Path] = Path("~/.claude").expanduser()
|
|
26
|
+
CONFIG_DIR_ENV: ClassVar[str] = "CLAUDE_CONFIG_DIR"
|
|
27
|
+
# read recursively, so our rules sit one level down beside the project's own
|
|
28
|
+
PROJECT_RULES_DIR = ".claude/rules"
|
|
29
|
+
PROJECT_SKILLS_DIR = ".claude/skills"
|
|
30
|
+
# a session waits on its start hooks, so ours has to be quick or get out of the way
|
|
31
|
+
HOOK_TIMEOUT_SECONDS: ClassVar[int] = 10
|
|
32
|
+
|
|
33
|
+
def __init__(self, config_dir: Path) -> None:
|
|
34
|
+
self._config_dir = config_dir
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def config_dir(self) -> Path:
|
|
38
|
+
return self._config_dir
|
|
39
|
+
|
|
40
|
+
def bridge(self, installation: Installation) -> tuple[Path, ...]:
|
|
41
|
+
"""Claude's user-level surfaces, and the session hook that keeps every project in sync."""
|
|
42
|
+
return super().bridge(installation) + self._register_hook(installation)
|
|
43
|
+
|
|
44
|
+
def unbridge(self, installation: Installation) -> None:
|
|
45
|
+
"""Mirror of bridge(), the session hook included."""
|
|
46
|
+
self._strip_hook(installation, self.config_dir / "settings.json")
|
|
47
|
+
super().unbridge(installation)
|
|
48
|
+
|
|
49
|
+
def our_hook(self, installation: Installation, hook: dict) -> bool:
|
|
50
|
+
"""Ours by the command it calls, whatever path or entry-point name it was written with."""
|
|
51
|
+
# the hook line guards before it calls, so what it actually runs is the last statement
|
|
52
|
+
words = shlex.split(hook.get("command", "").rsplit(";", 1)[-1])
|
|
53
|
+
return bool(words) and Path(words[0]).name == COMMAND_NAME and "sync" in words[1:]
|
|
54
|
+
|
|
55
|
+
def _register_hook(self, installation: Installation) -> tuple[Path, ...]:
|
|
56
|
+
"""Put our SessionStart hook in this config dir's settings, keeping every hook that is already there."""
|
|
57
|
+
settings_file = self.config_dir / "settings.json"
|
|
58
|
+
settings = json.loads(settings_file.read_text()) if settings_file.is_file() else {}
|
|
59
|
+
was = json.dumps(settings, sort_keys=True)
|
|
60
|
+
entries = settings.setdefault("hooks", {}).setdefault("SessionStart", [])
|
|
61
|
+
# replace-not-append: our own entries go first, so reruns and a moved install converge on one line
|
|
62
|
+
self._drop_our_entries(installation, entries)
|
|
63
|
+
hook = {"type": "command", "command": self._hook_command(installation), "timeout": self.HOOK_TIMEOUT_SECONDS}
|
|
64
|
+
entries.append({"hooks": [hook]})
|
|
65
|
+
if json.dumps(settings, sort_keys=True) != was:
|
|
66
|
+
settings_file.parent.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
settings_file.write_text(json.dumps(settings, indent=2) + "\n")
|
|
68
|
+
return (settings_file,)
|
|
69
|
+
|
|
70
|
+
def _strip_hook(self, installation: Installation, settings_file: Path) -> None:
|
|
71
|
+
"""Remove exactly our entries from a settings file, and any key or file that empties out."""
|
|
72
|
+
if not settings_file.is_file():
|
|
73
|
+
return
|
|
74
|
+
settings = json.loads(settings_file.read_text())
|
|
75
|
+
entries = settings.get("hooks", {}).get("SessionStart")
|
|
76
|
+
if entries is None:
|
|
77
|
+
return
|
|
78
|
+
self._drop_our_entries(installation, entries)
|
|
79
|
+
if not entries:
|
|
80
|
+
del settings["hooks"]["SessionStart"]
|
|
81
|
+
if not settings["hooks"]:
|
|
82
|
+
del settings["hooks"]
|
|
83
|
+
if not settings:
|
|
84
|
+
# an empty settings file is litter, and Claude reads its absence the same way
|
|
85
|
+
settings_file.unlink()
|
|
86
|
+
paths.remove_if_empty(settings_file.parent)
|
|
87
|
+
return
|
|
88
|
+
settings_file.write_text(json.dumps(settings, indent=2) + "\n")
|
|
89
|
+
|
|
90
|
+
def _drop_our_entries(self, installation: Installation, entries: list[dict]) -> None:
|
|
91
|
+
for entry in entries:
|
|
92
|
+
entry["hooks"] = [hook for hook in entry.get("hooks", []) if not self.our_hook(installation, hook)]
|
|
93
|
+
entries[:] = [entry for entry in entries if entry.get("hooks")]
|
|
94
|
+
|
|
95
|
+
def _hook_command(self, installation: Installation) -> str:
|
|
96
|
+
"""The guard is the opt-in: a project with no declaration never calls us, and the hook still exits clean."""
|
|
97
|
+
# the profile is named, so a session syncs the config dir it was launched with
|
|
98
|
+
return (
|
|
99
|
+
f'[ -f "$CLAUDE_PROJECT_DIR/{Declaration.FILE_NAME}" ] || exit 0; '
|
|
100
|
+
f"{self._loom_command()} --config-dir {shlex.quote(str(self.config_dir))} "
|
|
101
|
+
f'sync --for {self.NAME} "$CLAUDE_PROJECT_DIR"'
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def _loom_command(self) -> str:
|
|
105
|
+
"""How the hook calls us: by the full path we were started from, since its shell may not have us on PATH."""
|
|
106
|
+
found = shutil.which(sys.argv[0])
|
|
107
|
+
# started as anything but our own command (a test runner, python -m), the bare name is all we know
|
|
108
|
+
if not found or Path(found).name != COMMAND_NAME:
|
|
109
|
+
return COMMAND_NAME
|
|
110
|
+
return shlex.quote(str(Path(found).absolute()))
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from loomwork.agents.base import Agent
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class OpenCode(Agent):
|
|
10
|
+
"""Instruction follower: its global AGENTS.md sends it to the rules, since it loads no rules dir on its own."""
|
|
11
|
+
|
|
12
|
+
NAME = "opencode"
|
|
13
|
+
|
|
14
|
+
COMMAND = "opencode"
|
|
15
|
+
AGENTS_MD_NAME = "AGENTS.md"
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def config_dir(self) -> Path:
|
|
19
|
+
# the XDG spec's config root, where an empty value counts as unset — as it does for OpenCode
|
|
20
|
+
return Path(os.environ.get("XDG_CONFIG_HOME") or "~/.config").expanduser() / "opencode"
|
loomwork/cli.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from loomwork import COMMAND_NAME
|
|
9
|
+
from loomwork.agents import ClaudeCode
|
|
10
|
+
from loomwork.declaration import Declaration
|
|
11
|
+
from loomwork.loom import Loom
|
|
12
|
+
from loomwork.questions import declare_project, wanted_agents, wanted_config_dir, wanted_source
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def project_dir(project: Path | None) -> Path:
|
|
16
|
+
return (project or Path.cwd()).resolve()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def profile(config_dir: Path | None) -> Loom:
|
|
20
|
+
"""The profile the flag names, else the one in Claude's default config dir."""
|
|
21
|
+
if config_dir:
|
|
22
|
+
return Loom(config_dir.expanduser())
|
|
23
|
+
return Loom(ClaudeCode.DEFAULT_CONFIG_DIR)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@click.group(COMMAND_NAME)
|
|
27
|
+
@click.option(
|
|
28
|
+
"--config-dir",
|
|
29
|
+
type=click.Path(file_okay=False, path_type=Path),
|
|
30
|
+
help="Claude's config dir, which this profile lives in. Defaults to ~/.claude; init asks when it is left out.",
|
|
31
|
+
)
|
|
32
|
+
@click.pass_context
|
|
33
|
+
def cli(context: click.Context, config_dir: Path | None) -> None:
|
|
34
|
+
"""Weave shared agent rules through your projects."""
|
|
35
|
+
context.obj = config_dir
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@cli.command()
|
|
39
|
+
@click.argument("project", required=False, type=click.Path(file_okay=False, path_type=Path))
|
|
40
|
+
@click.option("--agents", "chosen", metavar="NAMES", help="Comma-separated agents to wire, instead of being asked.")
|
|
41
|
+
@click.option(
|
|
42
|
+
"--source",
|
|
43
|
+
"given_source",
|
|
44
|
+
help="Where the rules come from: a checkout, or <host>/<owner>/<repo>. Asked for when nothing else names one.",
|
|
45
|
+
)
|
|
46
|
+
@click.pass_obj
|
|
47
|
+
def init(config_dir: Path | None, project: Path | None, chosen: str | None, given_source: str | None) -> None:
|
|
48
|
+
"""Wire PROJECT and set up this profile.
|
|
49
|
+
|
|
50
|
+
PROJECT defaults to the current directory.
|
|
51
|
+
"""
|
|
52
|
+
target = project_dir(project)
|
|
53
|
+
# the profile first: everything below is written into it
|
|
54
|
+
loom = Loom(wanted_config_dir(config_dir, target))
|
|
55
|
+
agents = wanted_agents(loom, target, chosen)
|
|
56
|
+
loom.init(target, agents, wanted_source(loom, target, given_source))
|
|
57
|
+
# the profile is set up either way; the project only joins in when it declares what it wants
|
|
58
|
+
if not Declaration.read(target) and not declare_project(loom, target):
|
|
59
|
+
return
|
|
60
|
+
loom.add(target, agents)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@cli.command()
|
|
64
|
+
@click.argument("project", required=False, type=click.Path(file_okay=False, path_type=Path))
|
|
65
|
+
@click.pass_obj
|
|
66
|
+
def remove(config_dir: Path | None, project: Path | None) -> None:
|
|
67
|
+
"""Unwire PROJECT, undoing what loom put there.
|
|
68
|
+
|
|
69
|
+
PROJECT defaults to the current directory.
|
|
70
|
+
"""
|
|
71
|
+
profile(config_dir).remove(project_dir(project))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@cli.command()
|
|
75
|
+
@click.argument("project", required=False, type=click.Path(file_okay=False, path_type=Path))
|
|
76
|
+
@click.option("--all", "everywhere", is_flag=True, help="Refresh every wired project instead of this one.")
|
|
77
|
+
@click.option("--for", "asked_by", metavar="NAME", help="The agent asking, as its session hook does.")
|
|
78
|
+
@click.pass_obj
|
|
79
|
+
def sync(config_dir: Path | None, project: Path | None, everywhere: bool, asked_by: str | None) -> None:
|
|
80
|
+
"""Refresh PROJECT from its sources.
|
|
81
|
+
|
|
82
|
+
PROJECT defaults to the current directory.
|
|
83
|
+
"""
|
|
84
|
+
target = None if everywhere else project_dir(project)
|
|
85
|
+
profile(config_dir).sync(target, asked_by)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@cli.command()
|
|
89
|
+
@click.pass_obj
|
|
90
|
+
def status(config_dir: Path | None) -> None:
|
|
91
|
+
"""Show the sources and the wired projects."""
|
|
92
|
+
profile(config_dir).status()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@cli.command()
|
|
96
|
+
@click.pass_obj
|
|
97
|
+
def uninstall(config_dir: Path | None) -> None:
|
|
98
|
+
"""Undo everything this profile installed."""
|
|
99
|
+
profile(config_dir).uninstall()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
if __name__ == "__main__":
|
|
103
|
+
cli()
|
loomwork/declaration.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import ClassVar
|
|
7
|
+
|
|
8
|
+
from loomwork.output import error
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class DeclaredSource:
|
|
13
|
+
"""One [[source]] block of a declaration: where the rules repo is, and what the project takes from it."""
|
|
14
|
+
|
|
15
|
+
source: str
|
|
16
|
+
rules: tuple[str, ...] = ()
|
|
17
|
+
skills: tuple[str, ...] = ()
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
def of(cls, declared: dict) -> DeclaredSource:
|
|
21
|
+
return cls(
|
|
22
|
+
source=declared["source"],
|
|
23
|
+
rules=tuple(declared.get("rules", ())),
|
|
24
|
+
skills=tuple(declared.get("skills", ())),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
def spelled(self) -> list[str]:
|
|
28
|
+
"""The block's TOML lines, leaving out an empty rules or skills list."""
|
|
29
|
+
lines = [f'source = "{self.source}"']
|
|
30
|
+
for key, names in (("rules", self.rules), ("skills", self.skills)):
|
|
31
|
+
if names:
|
|
32
|
+
quoted = ", ".join(f'"{name}"' for name in names)
|
|
33
|
+
lines.append(f"{key} = [{quoted}]")
|
|
34
|
+
return lines
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Declaration:
|
|
39
|
+
"""A project's .agents.toml: the sources it uses and what it takes from each. Its presence is the opt-in."""
|
|
40
|
+
|
|
41
|
+
FILE_NAME: ClassVar[str] = ".agents.toml"
|
|
42
|
+
TABLE: ClassVar[str] = "source"
|
|
43
|
+
|
|
44
|
+
sources: tuple[DeclaredSource, ...]
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def read(cls, project_dir: Path) -> Declaration | None:
|
|
48
|
+
"""The project's declaration, or None when it carries none — which reads as the opt-out."""
|
|
49
|
+
declaration_file = project_dir / cls.FILE_NAME
|
|
50
|
+
if not declaration_file.is_file():
|
|
51
|
+
return None
|
|
52
|
+
declared = _parsed(declaration_file)
|
|
53
|
+
listed = declared.get(cls.TABLE)
|
|
54
|
+
if isinstance(listed, list):
|
|
55
|
+
sources = tuple(DeclaredSource.of(entry) for entry in listed if entry.get("source"))
|
|
56
|
+
if not sources:
|
|
57
|
+
return None
|
|
58
|
+
return cls(sources)
|
|
59
|
+
if not declared.get("source"):
|
|
60
|
+
return None
|
|
61
|
+
return cls((DeclaredSource.of(declared),))
|
|
62
|
+
|
|
63
|
+
def write(self, project_dir: Path) -> Path:
|
|
64
|
+
"""Write the file into the project, returning its path."""
|
|
65
|
+
declaration_file = project_dir / self.FILE_NAME
|
|
66
|
+
declaration_file.write_text("\n".join(self._lines()) + "\n")
|
|
67
|
+
return declaration_file
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def rules(self) -> tuple[str, ...]:
|
|
71
|
+
"""Every rule this project asks for, in the order its sources were declared."""
|
|
72
|
+
return tuple(name for declared in self.sources for name in declared.rules)
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def skills(self) -> tuple[str, ...]:
|
|
76
|
+
return tuple(name for declared in self.sources for name in declared.skills)
|
|
77
|
+
|
|
78
|
+
def _lines(self) -> list[str]:
|
|
79
|
+
lines = []
|
|
80
|
+
for declared in self.sources:
|
|
81
|
+
lines += [f"[[{self.TABLE}]]", *declared.spelled(), ""]
|
|
82
|
+
return lines[:-1]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class Manifest:
|
|
87
|
+
"""A source's loom.toml: the sources it builds on, and the rules and skills it hands every project."""
|
|
88
|
+
|
|
89
|
+
FILE_NAME: ClassVar[str] = "loom.toml"
|
|
90
|
+
TABLE: ClassVar[str] = "global"
|
|
91
|
+
|
|
92
|
+
# the same [[source]] blocks a project writes, meaning "I build on this" rather than "I take this"
|
|
93
|
+
builds_on: tuple[str, ...] = ()
|
|
94
|
+
global_rules: tuple[str, ...] = ()
|
|
95
|
+
global_skills: tuple[str, ...] = ()
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def read(cls, source: Path) -> Manifest:
|
|
99
|
+
"""The source's manifest — an empty one when it has no loom.toml."""
|
|
100
|
+
manifest_file = source / cls.FILE_NAME
|
|
101
|
+
if not manifest_file.is_file():
|
|
102
|
+
return cls()
|
|
103
|
+
document = _parsed(manifest_file)
|
|
104
|
+
marked_global = document.get(cls.TABLE, {})
|
|
105
|
+
blocks = document.get(Declaration.TABLE)
|
|
106
|
+
if not isinstance(blocks, list):
|
|
107
|
+
blocks = []
|
|
108
|
+
return cls(
|
|
109
|
+
builds_on=tuple(entry["source"] for entry in blocks if entry.get("source")),
|
|
110
|
+
global_rules=tuple(marked_global.get("rules", ())),
|
|
111
|
+
global_skills=tuple(marked_global.get("skills", ())),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _parsed(toml_file: Path) -> dict:
|
|
116
|
+
"""The file's contents, or a one-line failure naming it — a session hook's error shows its first line only."""
|
|
117
|
+
try:
|
|
118
|
+
return tomllib.loads(toml_file.read_text())
|
|
119
|
+
except tomllib.TOMLDecodeError as broken:
|
|
120
|
+
error(f"{toml_file}: {broken}")
|
loomwork/git.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from subprocess import CompletedProcess
|
|
7
|
+
|
|
8
|
+
# a session's first answer waits on the hook that updates, so a slow network has to lose rather than hold it up
|
|
9
|
+
UPDATE_TIMEOUT_SECONDS = 8
|
|
10
|
+
CLONE_TIMEOUT_SECONDS = 120
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run(*arguments: str, inside: Path | None = None, timeout: float | None = None) -> CompletedProcess[str] | None:
|
|
14
|
+
"""One git call, or None when it failed — the user's own git, so their ssh agent and credential helpers apply."""
|
|
15
|
+
located = ["-C", str(inside)] if inside else []
|
|
16
|
+
# nobody is there to type a password or accept a host key, so git must fail instead of waiting for one
|
|
17
|
+
unattended = {
|
|
18
|
+
"GIT_TERMINAL_PROMPT": "0",
|
|
19
|
+
"GIT_SSH_COMMAND": os.environ.get("GIT_SSH_COMMAND", "ssh -o BatchMode=yes"),
|
|
20
|
+
}
|
|
21
|
+
try:
|
|
22
|
+
ran = subprocess.run(
|
|
23
|
+
["git", *located, *arguments],
|
|
24
|
+
capture_output=True,
|
|
25
|
+
text=True,
|
|
26
|
+
timeout=timeout,
|
|
27
|
+
check=False,
|
|
28
|
+
env={**os.environ, **unattended},
|
|
29
|
+
)
|
|
30
|
+
except subprocess.TimeoutExpired:
|
|
31
|
+
# macOS ships no timeout(1), so this bound is the only one the hook gets
|
|
32
|
+
return None
|
|
33
|
+
return ran if ran.returncode == 0 else None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def clone(url: str, into: Path) -> bool:
|
|
37
|
+
"""Clone the tip alone — what loom reads, with no history to grow."""
|
|
38
|
+
return bool(run("clone", "--quiet", "--depth", "1", url, str(into), timeout=CLONE_TIMEOUT_SECONDS))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def pull(path: Path) -> bool:
|
|
42
|
+
"""Fast-forward a checkout, reporting whether it worked."""
|
|
43
|
+
return bool(run("pull", "--ff-only", "--quiet", inside=path, timeout=UPDATE_TIMEOUT_SECONDS))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def match_remote(path: Path) -> bool:
|
|
47
|
+
"""Make a clone exactly its remote's tip, rewritten history included, reporting whether it worked."""
|
|
48
|
+
if not run("fetch", "--quiet", "--depth", "1", "origin", inside=path, timeout=UPDATE_TIMEOUT_SECONDS):
|
|
49
|
+
return False
|
|
50
|
+
return bool(run("reset", "--quiet", "--hard", "FETCH_HEAD", inside=path))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def origin(path: Path) -> str | None:
|
|
54
|
+
"""The url a checkout came from — None when it has no origin, which a local working copy may not."""
|
|
55
|
+
asked = run("remote", "get-url", "origin", inside=path)
|
|
56
|
+
if asked is None:
|
|
57
|
+
return None
|
|
58
|
+
return asked.stdout.strip().rstrip("/").removesuffix(".git")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def root(path: Path) -> Path:
|
|
62
|
+
"""The repo a path sits in, or the path itself when it is in none."""
|
|
63
|
+
for candidate in (path, *path.parents):
|
|
64
|
+
if (candidate / ".git").exists():
|
|
65
|
+
return candidate
|
|
66
|
+
return path
|