workrail 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.
- workrail/__init__.py +3 -0
- workrail/__main__.py +9 -0
- workrail/cli.py +216 -0
- workrail/config.py +30 -0
- workrail/project.py +45 -0
- workrail/report.py +111 -0
- workrail/safety.py +48 -0
- workrail/skill.py +76 -0
- workrail/skills/workrail-agent/SKILL.md +275 -0
- workrail/stats.py +344 -0
- workrail/storage.py +354 -0
- workrail/web.py +860 -0
- workrail-0.1.0.dist-info/METADATA +227 -0
- workrail-0.1.0.dist-info/RECORD +18 -0
- workrail-0.1.0.dist-info/WHEEL +5 -0
- workrail-0.1.0.dist-info/entry_points.txt +2 -0
- workrail-0.1.0.dist-info/licenses/LICENSE +21 -0
- workrail-0.1.0.dist-info/top_level.txt +1 -0
workrail/__init__.py
ADDED
workrail/__main__.py
ADDED
workrail/cli.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, TextIO
|
|
8
|
+
|
|
9
|
+
from workrail import __version__
|
|
10
|
+
from workrail.project import detect_project
|
|
11
|
+
from workrail.report import render_report, render_today, write_report
|
|
12
|
+
from workrail.skill import install_skill
|
|
13
|
+
from workrail.storage import ActiveRailExistsError, NoActiveRailError, Storage
|
|
14
|
+
from workrail.web import run_server
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
SOURCES = ("human", "agent")
|
|
18
|
+
LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
22
|
+
parser = argparse.ArgumentParser(prog="workrail", description="Local workstream journal.")
|
|
23
|
+
parser.add_argument("--version", action="version", version=f"workrail {__version__}")
|
|
24
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
25
|
+
|
|
26
|
+
start = subparsers.add_parser("start", help="Start a new rail.")
|
|
27
|
+
start.add_argument("title")
|
|
28
|
+
start.add_argument("--source", choices=SOURCES, default="human")
|
|
29
|
+
start.add_argument("--project", default="auto")
|
|
30
|
+
start.add_argument("--quiet", action="store_true")
|
|
31
|
+
start.add_argument("--json", dest="json_output", action="store_true")
|
|
32
|
+
start.add_argument("--force", action="store_true")
|
|
33
|
+
|
|
34
|
+
for name, event_type, help_text in [
|
|
35
|
+
("note", "note", "Record a note."),
|
|
36
|
+
("n", "note", "Record a note."),
|
|
37
|
+
("checkpoint", "checkpoint", "Record a checkpoint."),
|
|
38
|
+
("block", "block", "Record a blocker."),
|
|
39
|
+
("next", "next", "Record the next step."),
|
|
40
|
+
("done", "done", "Complete the current rail."),
|
|
41
|
+
("abandon", "abandon", "Abandon the current rail."),
|
|
42
|
+
]:
|
|
43
|
+
event = subparsers.add_parser(name, help=help_text)
|
|
44
|
+
event.add_argument("content")
|
|
45
|
+
event.add_argument("--source", choices=SOURCES, default="human")
|
|
46
|
+
event.add_argument("--quiet", action="store_true")
|
|
47
|
+
event.add_argument("--json", dest="json_output", action="store_true")
|
|
48
|
+
event.set_defaults(event_type=event_type)
|
|
49
|
+
|
|
50
|
+
current = subparsers.add_parser("current", help="Show the current rail.")
|
|
51
|
+
current.add_argument("--json", dest="json_output", action="store_true")
|
|
52
|
+
|
|
53
|
+
today = subparsers.add_parser("today", help="Show today's workstream.")
|
|
54
|
+
today.add_argument("--json", dest="json_output", action="store_true")
|
|
55
|
+
|
|
56
|
+
report = subparsers.add_parser("report", help="Generate a Markdown report.")
|
|
57
|
+
report.add_argument("period", choices=("today", "week"))
|
|
58
|
+
report.add_argument("--output")
|
|
59
|
+
|
|
60
|
+
web = subparsers.add_parser("web", help="Start the local dashboard.")
|
|
61
|
+
web.add_argument("--host", default="127.0.0.1")
|
|
62
|
+
web.add_argument("--port", type=int, default=18765)
|
|
63
|
+
|
|
64
|
+
skill = subparsers.add_parser("install-skill", help="Install the bundled agent skill.")
|
|
65
|
+
skill.add_argument("--target", help="Target skills directory.")
|
|
66
|
+
skill.add_argument("--agent", choices=("auto", "codex", "claude", "opencode"), default="auto")
|
|
67
|
+
skill.add_argument("--force", action="store_true", help="Replace an existing skill.")
|
|
68
|
+
skill.add_argument("--source", help="Use a custom skill source directory.")
|
|
69
|
+
|
|
70
|
+
return parser
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def main(argv: list[str] | None = None, out: TextIO | None = None, err: TextIO | None = None) -> int:
|
|
74
|
+
output = out or sys.stdout
|
|
75
|
+
error = err or sys.stderr
|
|
76
|
+
parser = build_parser()
|
|
77
|
+
args = parser.parse_args(argv)
|
|
78
|
+
storage = Storage()
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
if args.command == "start":
|
|
82
|
+
return _handle_start(storage, args, output)
|
|
83
|
+
if args.command in {"note", "n", "checkpoint", "block", "next", "done", "abandon"}:
|
|
84
|
+
return _handle_event(storage, args, output, error)
|
|
85
|
+
if args.command == "current":
|
|
86
|
+
return _handle_current(storage, args, output)
|
|
87
|
+
if args.command == "today":
|
|
88
|
+
return _handle_today(storage, args, output)
|
|
89
|
+
if args.command == "report":
|
|
90
|
+
return _handle_report(storage, args, output)
|
|
91
|
+
if args.command == "web":
|
|
92
|
+
if args.host not in LOOPBACK_HOSTS:
|
|
93
|
+
print(
|
|
94
|
+
f"WARNING: Binding the WorkRail dashboard to {args.host} exposes it "
|
|
95
|
+
"without authentication. Use 127.0.0.1 unless you trust this network.",
|
|
96
|
+
file=error,
|
|
97
|
+
)
|
|
98
|
+
try:
|
|
99
|
+
run_server(args.host, args.port, storage)
|
|
100
|
+
except OSError as exc:
|
|
101
|
+
print(f"Could not start dashboard: {exc}. Try --port with another value.", file=error)
|
|
102
|
+
return 2
|
|
103
|
+
return 0
|
|
104
|
+
if args.command == "install-skill":
|
|
105
|
+
destination = install_skill(
|
|
106
|
+
args.target,
|
|
107
|
+
agent=args.agent,
|
|
108
|
+
force=args.force,
|
|
109
|
+
source=args.source,
|
|
110
|
+
)
|
|
111
|
+
print(f"Installed skill: {destination}", file=output)
|
|
112
|
+
return 0
|
|
113
|
+
except (ActiveRailExistsError, NoActiveRailError) as exc:
|
|
114
|
+
print(str(exc), file=error)
|
|
115
|
+
return 2
|
|
116
|
+
except (FileExistsError, FileNotFoundError, ValueError) as exc:
|
|
117
|
+
print(str(exc), file=error)
|
|
118
|
+
return 2
|
|
119
|
+
|
|
120
|
+
parser.error(f"Unsupported command: {args.command}")
|
|
121
|
+
return 2
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _handle_start(storage: Storage, args: argparse.Namespace, output: TextIO) -> int:
|
|
125
|
+
if args.project == "auto":
|
|
126
|
+
project_info = detect_project()
|
|
127
|
+
project = project_info.project
|
|
128
|
+
branch = project_info.branch
|
|
129
|
+
cwd = project_info.cwd
|
|
130
|
+
else:
|
|
131
|
+
project_info = detect_project()
|
|
132
|
+
project = args.project
|
|
133
|
+
branch = project_info.branch
|
|
134
|
+
cwd = project_info.cwd
|
|
135
|
+
|
|
136
|
+
rail = storage.create_rail(
|
|
137
|
+
title=args.title,
|
|
138
|
+
project=project,
|
|
139
|
+
branch=branch,
|
|
140
|
+
cwd=cwd,
|
|
141
|
+
source=args.source,
|
|
142
|
+
force=args.force,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
if args.json_output:
|
|
146
|
+
print(json.dumps(_rail_payload(rail), indent=2), file=output)
|
|
147
|
+
elif not args.quiet:
|
|
148
|
+
print(f"Started: {rail['title']}", file=output)
|
|
149
|
+
print(f"Rail ID: {rail['id']}", file=output)
|
|
150
|
+
return 0
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _handle_event(
|
|
154
|
+
storage: Storage, args: argparse.Namespace, output: TextIO, error: TextIO
|
|
155
|
+
) -> int:
|
|
156
|
+
del error
|
|
157
|
+
event = storage.add_event_to_current(args.event_type, args.content, args.source)
|
|
158
|
+
if args.json_output:
|
|
159
|
+
print(json.dumps(event, indent=2), file=output)
|
|
160
|
+
elif not args.quiet:
|
|
161
|
+
verb = "Completed" if args.event_type == "done" else "Recorded"
|
|
162
|
+
print(f"{verb} {args.event_type}: {event['content']}", file=output)
|
|
163
|
+
return 0
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _handle_current(storage: Storage, args: argparse.Namespace, output: TextIO) -> int:
|
|
167
|
+
rail = storage.current_rail()
|
|
168
|
+
payload = _rail_payload(rail) if rail else {"active": False}
|
|
169
|
+
if args.json_output:
|
|
170
|
+
print(json.dumps(payload, indent=2), file=output)
|
|
171
|
+
elif rail:
|
|
172
|
+
print(f"{rail['id']} {rail['title']} [{rail['status']}]", file=output)
|
|
173
|
+
print(f"Project: {rail['project'] or '-'}", file=output)
|
|
174
|
+
else:
|
|
175
|
+
print("No active rail.", file=output)
|
|
176
|
+
return 0
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _handle_today(storage: Storage, args: argparse.Namespace, output: TextIO) -> int:
|
|
180
|
+
if args.json_output:
|
|
181
|
+
from datetime import date
|
|
182
|
+
|
|
183
|
+
day = date.today().isoformat()
|
|
184
|
+
payload = {
|
|
185
|
+
"date": day,
|
|
186
|
+
"rails": storage.rails_with_events_between(day, day),
|
|
187
|
+
"events": storage.events_between(day, day),
|
|
188
|
+
}
|
|
189
|
+
print(json.dumps(payload, indent=2), file=output)
|
|
190
|
+
else:
|
|
191
|
+
print(render_today(storage), file=output)
|
|
192
|
+
return 0
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _handle_report(storage: Storage, args: argparse.Namespace, output: TextIO) -> int:
|
|
196
|
+
content = render_report(storage, args.period)
|
|
197
|
+
if args.output:
|
|
198
|
+
write_report(Path(args.output), content)
|
|
199
|
+
print(content, file=output)
|
|
200
|
+
return 0
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _rail_payload(rail: dict[str, Any]) -> dict[str, Any]:
|
|
204
|
+
return {
|
|
205
|
+
"active": rail["status"] in {"in_progress", "blocked"},
|
|
206
|
+
"id": rail["id"],
|
|
207
|
+
"title": rail["title"],
|
|
208
|
+
"project": rail["project"],
|
|
209
|
+
"branch": rail["branch"],
|
|
210
|
+
"cwd": rail["cwd"],
|
|
211
|
+
"status": rail["status"],
|
|
212
|
+
"source": rail["source"],
|
|
213
|
+
"started_at": rail["started_at"],
|
|
214
|
+
"ended_at": rail["ended_at"],
|
|
215
|
+
"updated_at": rail["updated_at"],
|
|
216
|
+
}
|
workrail/config.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
APP_NAME = "WorkRail"
|
|
9
|
+
DB_ENV_VAR = "WORKRAIL_DB"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def default_data_dir() -> Path:
|
|
13
|
+
if sys.platform == "win32":
|
|
14
|
+
root = os.environ.get("LOCALAPPDATA")
|
|
15
|
+
if root:
|
|
16
|
+
return Path(root) / APP_NAME
|
|
17
|
+
return Path.home() / "AppData" / "Local" / APP_NAME
|
|
18
|
+
if sys.platform == "darwin":
|
|
19
|
+
return Path.home() / "Library" / "Application Support" / "workrail"
|
|
20
|
+
root = os.environ.get("XDG_DATA_HOME")
|
|
21
|
+
if root:
|
|
22
|
+
return Path(root) / "workrail"
|
|
23
|
+
return Path.home() / ".local" / "share" / "workrail"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_db_path() -> Path:
|
|
27
|
+
override = os.environ.get(DB_ENV_VAR)
|
|
28
|
+
if override:
|
|
29
|
+
return Path(override).expanduser().resolve()
|
|
30
|
+
return default_data_dir() / "workrail.db"
|
workrail/project.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class ProjectInfo:
|
|
10
|
+
project: str
|
|
11
|
+
branch: str | None
|
|
12
|
+
cwd: str
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _git(args: list[str], cwd: Path) -> str | None:
|
|
16
|
+
try:
|
|
17
|
+
result = subprocess.run(
|
|
18
|
+
["git", *args],
|
|
19
|
+
cwd=str(cwd),
|
|
20
|
+
capture_output=True,
|
|
21
|
+
check=False,
|
|
22
|
+
text=True,
|
|
23
|
+
timeout=2,
|
|
24
|
+
)
|
|
25
|
+
except (FileNotFoundError, subprocess.SubprocessError):
|
|
26
|
+
return None
|
|
27
|
+
if result.returncode != 0:
|
|
28
|
+
return None
|
|
29
|
+
value = result.stdout.strip()
|
|
30
|
+
return value or None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def detect_project(cwd: str | Path | None = None) -> ProjectInfo:
|
|
34
|
+
current = Path(cwd or Path.cwd()).resolve()
|
|
35
|
+
root_text = _git(["rev-parse", "--show-toplevel"], current)
|
|
36
|
+
branch = _git(["rev-parse", "--abbrev-ref", "HEAD"], current)
|
|
37
|
+
|
|
38
|
+
if root_text:
|
|
39
|
+
root = Path(root_text).resolve()
|
|
40
|
+
project = root.name
|
|
41
|
+
else:
|
|
42
|
+
project = current.name
|
|
43
|
+
branch = None
|
|
44
|
+
|
|
45
|
+
return ProjectInfo(project=project, branch=branch, cwd=str(current))
|
workrail/report.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from datetime import date
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from workrail.storage import DateRange, Storage
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
REPORT_EVENT_ORDER = {"done": 0, "checkpoint": 1, "block": 2, "next": 3, "note": 4, "start": 5}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def render_report(storage: Storage, period: str = "today") -> str:
|
|
15
|
+
date_range = DateRange.for_period(period)
|
|
16
|
+
rails = storage.rails_with_events_between(date_range.start, date_range.end)
|
|
17
|
+
|
|
18
|
+
if period == "week":
|
|
19
|
+
title = f"# Weekly Report - {date_range.start} to {date_range.end}"
|
|
20
|
+
else:
|
|
21
|
+
title = f"# Daily Report - {date.today().isoformat()}"
|
|
22
|
+
|
|
23
|
+
completed: list[str] = []
|
|
24
|
+
in_progress: list[str] = []
|
|
25
|
+
blockers: list[str] = []
|
|
26
|
+
next_steps: list[str] = []
|
|
27
|
+
|
|
28
|
+
for rail in rails:
|
|
29
|
+
events = rail.get("events", [])
|
|
30
|
+
if rail["status"] == "done":
|
|
31
|
+
completed.append(_rail_bullet(rail, events, {"done", "checkpoint", "block"}))
|
|
32
|
+
elif rail["status"] in {"in_progress", "blocked"}:
|
|
33
|
+
in_progress.append(_rail_bullet(rail, events, {"checkpoint", "note", "next", "block"}))
|
|
34
|
+
|
|
35
|
+
block_events = [event for event in events if event["type"] == "block"]
|
|
36
|
+
for event in block_events:
|
|
37
|
+
blockers.append(f"- {rail['title']}: {event['content']}")
|
|
38
|
+
|
|
39
|
+
next_events = [event for event in events if event["type"] == "next"]
|
|
40
|
+
for event in next_events:
|
|
41
|
+
next_steps.append(f"- {rail['title']}: {event['content']}")
|
|
42
|
+
|
|
43
|
+
sections = [
|
|
44
|
+
title,
|
|
45
|
+
"",
|
|
46
|
+
"## Completed",
|
|
47
|
+
*(completed or ["- None"]),
|
|
48
|
+
"",
|
|
49
|
+
"## In Progress",
|
|
50
|
+
*(in_progress or ["- None"]),
|
|
51
|
+
"",
|
|
52
|
+
"## Blockers",
|
|
53
|
+
*(blockers or ["- None"]),
|
|
54
|
+
"",
|
|
55
|
+
"## Next Steps",
|
|
56
|
+
*(next_steps or ["- None"]),
|
|
57
|
+
"",
|
|
58
|
+
]
|
|
59
|
+
return "\n".join(sections)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def render_today(storage: Storage) -> str:
|
|
63
|
+
day = date.today().isoformat()
|
|
64
|
+
rails = storage.rails_with_events_between(day, day)
|
|
65
|
+
lines = [f"Today - {day}", ""]
|
|
66
|
+
if not rails:
|
|
67
|
+
lines.append("No work events recorded.")
|
|
68
|
+
return "\n".join(lines)
|
|
69
|
+
|
|
70
|
+
for rail in rails:
|
|
71
|
+
lines.append(f"{rail['id']} {rail['title']}")
|
|
72
|
+
for event in rail.get("events", []):
|
|
73
|
+
time_text = event["created_at"][11:16]
|
|
74
|
+
lines.append(f" {time_text} {event['type']} {event['content']}")
|
|
75
|
+
lines.append("")
|
|
76
|
+
return "\n".join(lines).rstrip()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def write_report(path: str | Path, content: str) -> None:
|
|
80
|
+
Path(path).expanduser().write_text(content, encoding="utf-8")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _rail_bullet(rail: dict[str, Any], events: list[dict[str, Any]], allowed: set[str]) -> str:
|
|
84
|
+
lines = [f"- {rail['title']}"]
|
|
85
|
+
selected = [event for event in events if event["type"] in allowed and event["type"] != "start"]
|
|
86
|
+
selected.sort(key=lambda event: (REPORT_EVENT_ORDER.get(event["type"], 99), event["created_at"]))
|
|
87
|
+
|
|
88
|
+
if not selected:
|
|
89
|
+
return lines[0]
|
|
90
|
+
|
|
91
|
+
note_count = 0
|
|
92
|
+
for event in selected:
|
|
93
|
+
if event["type"] == "note":
|
|
94
|
+
note_count += 1
|
|
95
|
+
if note_count > 2:
|
|
96
|
+
continue
|
|
97
|
+
prefix = "next: " if event["type"] == "next" else ""
|
|
98
|
+
lines.append(f" - {prefix}{event['content']}")
|
|
99
|
+
return "\n".join(lines)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def summarize_by_status(rails: list[dict[str, Any]]) -> dict[str, int]:
|
|
103
|
+
counts: dict[str, int] = defaultdict(int)
|
|
104
|
+
for rail in rails:
|
|
105
|
+
counts[rail["status"]] += 1
|
|
106
|
+
return {
|
|
107
|
+
"done": counts["done"],
|
|
108
|
+
"in_progress": counts["in_progress"],
|
|
109
|
+
"blocked": counts["blocked"],
|
|
110
|
+
"abandoned": counts["abandoned"],
|
|
111
|
+
}
|
workrail/safety.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class SensitiveMatch:
|
|
9
|
+
rule: str
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
SENSITIVE_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
|
|
13
|
+
(
|
|
14
|
+
"secret-key-value",
|
|
15
|
+
re.compile(
|
|
16
|
+
r"\b(password|passwd|pwd|token|secret|api[_-]?key|apikey|access[_-]?key)"
|
|
17
|
+
r"\s*[:=]\s*['\"]?[^\s'\"]{6,}",
|
|
18
|
+
re.IGNORECASE,
|
|
19
|
+
),
|
|
20
|
+
),
|
|
21
|
+
(
|
|
22
|
+
"bearer-token",
|
|
23
|
+
re.compile(r"\bBearer\s+[A-Za-z0-9._\-]{20,}", re.IGNORECASE),
|
|
24
|
+
),
|
|
25
|
+
(
|
|
26
|
+
"private-key",
|
|
27
|
+
re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"),
|
|
28
|
+
),
|
|
29
|
+
(
|
|
30
|
+
"aws-access-key",
|
|
31
|
+
re.compile(r"\b(AKIA|ASIA)[0-9A-Z]{16}\b"),
|
|
32
|
+
),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def find_sensitive_content(content: str) -> SensitiveMatch | None:
|
|
37
|
+
for rule, pattern in SENSITIVE_PATTERNS:
|
|
38
|
+
if pattern.search(content):
|
|
39
|
+
return SensitiveMatch(rule=rule)
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def validate_safe_content(content: str) -> None:
|
|
44
|
+
match = find_sensitive_content(content)
|
|
45
|
+
if match:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"Refusing to record content that matches sensitive rule: {match.rule}."
|
|
48
|
+
)
|
workrail/skill.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
SKILL_NAME = "workrail-agent"
|
|
9
|
+
AGENTS = ("codex", "claude", "opencode")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def default_skill_target(agent: str = "codex") -> Path:
|
|
13
|
+
if agent == "codex":
|
|
14
|
+
return _codex_skill_target()
|
|
15
|
+
if agent == "claude":
|
|
16
|
+
return Path.home() / ".claude" / "skills"
|
|
17
|
+
if agent == "opencode":
|
|
18
|
+
return Path.home() / ".opencode" / "skills"
|
|
19
|
+
raise ValueError(f"Unsupported agent: {agent}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _codex_skill_target() -> Path:
|
|
23
|
+
codex_home = os.environ.get("CODEX_HOME")
|
|
24
|
+
root = Path(codex_home).expanduser() if codex_home else Path.home() / ".codex"
|
|
25
|
+
return root / "skills"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def resolve_skill_target(agent: str = "auto", target: str | Path | None = None) -> Path:
|
|
29
|
+
if target:
|
|
30
|
+
return Path(target).expanduser().resolve()
|
|
31
|
+
|
|
32
|
+
if agent != "auto":
|
|
33
|
+
return default_skill_target(agent).expanduser().resolve()
|
|
34
|
+
|
|
35
|
+
existing = [default_skill_target(name) for name in AGENTS if default_skill_target(name).exists()]
|
|
36
|
+
if len(existing) == 1:
|
|
37
|
+
return existing[0].expanduser().resolve()
|
|
38
|
+
if len(existing) > 1:
|
|
39
|
+
names = ", ".join(str(path) for path in existing)
|
|
40
|
+
raise ValueError(f"Multiple agent skill directories found: {names}. Use --agent or --target.")
|
|
41
|
+
return default_skill_target("codex").expanduser().resolve()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def find_skill_source() -> Path:
|
|
45
|
+
current = Path(__file__).resolve()
|
|
46
|
+
for parent in current.parents:
|
|
47
|
+
candidate = parent / "skills" / SKILL_NAME
|
|
48
|
+
if (candidate / "SKILL.md").is_file():
|
|
49
|
+
return candidate
|
|
50
|
+
raise FileNotFoundError("Bundled workrail-agent skill was not found.")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def install_skill(
|
|
54
|
+
target: str | Path | None = None,
|
|
55
|
+
*,
|
|
56
|
+
agent: str = "auto",
|
|
57
|
+
force: bool = False,
|
|
58
|
+
source: str | Path | None = None,
|
|
59
|
+
) -> Path:
|
|
60
|
+
source_path = Path(source).expanduser().resolve() if source else find_skill_source()
|
|
61
|
+
if not (source_path / "SKILL.md").is_file():
|
|
62
|
+
raise FileNotFoundError(f"Skill source is missing SKILL.md: {source_path}")
|
|
63
|
+
|
|
64
|
+
target_root = resolve_skill_target(agent, target)
|
|
65
|
+
destination = target_root if target_root.name == SKILL_NAME else target_root / SKILL_NAME
|
|
66
|
+
|
|
67
|
+
if destination.exists():
|
|
68
|
+
if not force:
|
|
69
|
+
raise FileExistsError(
|
|
70
|
+
f"Skill already exists at {destination}. Use --force to replace it."
|
|
71
|
+
)
|
|
72
|
+
shutil.rmtree(destination)
|
|
73
|
+
|
|
74
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
shutil.copytree(source_path, destination)
|
|
76
|
+
return destination
|