oasr 0.3.4__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.
commands/diff.py ADDED
@@ -0,0 +1,180 @@
1
+ """`oasr diff` command - show tracked skill status."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from manifest import load_manifest
11
+ from tracking import extract_metadata
12
+
13
+
14
+ def register(subparsers) -> None:
15
+ """Register the diff command."""
16
+ p = subparsers.add_parser("diff", help="Show status of tracked skills (copied with metadata)")
17
+ p.add_argument(
18
+ "path",
19
+ nargs="?",
20
+ type=Path,
21
+ default=Path.cwd(),
22
+ help="Path to scan for tracked skills (default: current directory)",
23
+ )
24
+ p.add_argument("--json", action="store_true", help="Output in JSON format")
25
+ p.add_argument("--quiet", action="store_true", help="Suppress info/warnings")
26
+ p.set_defaults(func=run)
27
+
28
+
29
+ def run(args: argparse.Namespace) -> int:
30
+ """Show status of tracked skills in the given path."""
31
+ scan_path = args.path.resolve()
32
+
33
+ if not scan_path.exists():
34
+ print(f"Error: Path does not exist: {scan_path}", file=sys.stderr)
35
+ return 1
36
+
37
+ # Find all SKILL.md files recursively
38
+ if not args.quiet and not args.json:
39
+ print(f"Scanning {scan_path} for tracked skills...", file=sys.stderr)
40
+
41
+ tracked_skills = []
42
+ skill_md_files = list(scan_path.rglob("SKILL.md"))
43
+
44
+ for skill_md in skill_md_files:
45
+ skill_dir = skill_md.parent
46
+ metadata = extract_metadata(skill_dir)
47
+
48
+ if metadata:
49
+ tracked_skills.append((skill_dir, metadata))
50
+
51
+ if not tracked_skills:
52
+ if args.json:
53
+ print(json.dumps({"tracked": 0, "skills": []}))
54
+ else:
55
+ print("No tracked skills found.")
56
+ return 0
57
+
58
+ # Determine status for each tracked skill
59
+ results = []
60
+ up_to_date = 0
61
+ outdated = 0
62
+ modified = 0
63
+ untracked = 0
64
+
65
+ for skill_dir, metadata in tracked_skills:
66
+ skill_name = skill_dir.name
67
+ tracked_hash = metadata.get("hash")
68
+ tracked_source = metadata.get("source")
69
+
70
+ # Validate metadata structure
71
+ if not tracked_hash or not tracked_source:
72
+ untracked += 1
73
+ results.append(
74
+ {
75
+ "name": skill_name,
76
+ "path": str(skill_dir),
77
+ "status": "error",
78
+ "message": "Corrupted metadata (missing hash or source)",
79
+ }
80
+ )
81
+ if not args.quiet and not args.json:
82
+ print(f" ✗ {skill_name}: corrupted metadata", file=sys.stderr)
83
+ continue
84
+
85
+ # Check if in registry
86
+ from registry import load_registry
87
+
88
+ try:
89
+ entries = load_registry()
90
+ except Exception as e:
91
+ if not args.quiet and not args.json:
92
+ print(f"Error loading registry: {e}", file=sys.stderr)
93
+ return 1
94
+
95
+ entry = next((e for e in entries if e.name == skill_name), None)
96
+
97
+ if entry:
98
+ try:
99
+ manifest = load_manifest(skill_name)
100
+ except Exception as e:
101
+ untracked += 1
102
+ results.append(
103
+ {"name": skill_name, "path": str(skill_dir), "status": "error", "message": f"Manifest error: {e}"}
104
+ )
105
+ if not args.quiet and not args.json:
106
+ print(f" ✗ {skill_name}: manifest error", file=sys.stderr)
107
+ continue
108
+
109
+ if manifest:
110
+ # Compare tracked hash with registry hash
111
+ if manifest.content_hash == tracked_hash:
112
+ # Up to date
113
+ status = "up-to-date"
114
+ up_to_date += 1
115
+ message = "Current"
116
+ else:
117
+ # Registry has changed
118
+ status = "outdated"
119
+ outdated += 1
120
+ message = "Registry has newer version"
121
+ else:
122
+ status = "untracked"
123
+ untracked += 1
124
+ message = "No manifest"
125
+ else:
126
+ status = "untracked"
127
+ untracked += 1
128
+ message = "Not in registry"
129
+
130
+ results.append(
131
+ {
132
+ "name": skill_name,
133
+ "path": str(skill_dir),
134
+ "status": status,
135
+ "message": message,
136
+ "tracked_hash": tracked_hash[:16] + "..." if tracked_hash else None,
137
+ "source": tracked_source,
138
+ }
139
+ )
140
+
141
+ if args.json:
142
+ print(
143
+ json.dumps(
144
+ {
145
+ "tracked": len(tracked_skills),
146
+ "up_to_date": up_to_date,
147
+ "outdated": outdated,
148
+ "modified": modified,
149
+ "untracked": untracked,
150
+ "skills": results,
151
+ },
152
+ indent=2,
153
+ )
154
+ )
155
+ else:
156
+ # Git-style output
157
+ for result in results:
158
+ if result["status"] == "up-to-date":
159
+ symbol = "✓"
160
+ elif result["status"] == "outdated":
161
+ symbol = "⚠"
162
+ elif result["status"] == "modified":
163
+ symbol = "✗"
164
+ else:
165
+ symbol = "?"
166
+
167
+ print(f"{symbol} {result['name']}: {result['status']}")
168
+ if not args.quiet:
169
+ print(f" Path: {result['path']}")
170
+ if result["message"]:
171
+ print(f" {result['message']}")
172
+
173
+ print(
174
+ f"\n{len(tracked_skills)} tracked: {up_to_date} up-to-date, {outdated} outdated, {modified} modified, {untracked} untracked"
175
+ )
176
+
177
+ if outdated > 0:
178
+ print("\nRun 'oasr sync' to update outdated skills.")
179
+
180
+ return 1 if outdated > 0 or modified > 0 else 0
commands/find.py ADDED
@@ -0,0 +1,56 @@
1
+ """`asr find` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from discovery import find_skills
11
+ from registry import SkillEntry, add_skill
12
+
13
+
14
+ def register(subparsers) -> None:
15
+ p = subparsers.add_parser("find", help="Find skills recursively")
16
+ p.add_argument("root", type=Path, help="Root directory to search")
17
+ p.add_argument("--add", action="store_true", dest="add_found", help="Register found skills")
18
+ p.add_argument("--json", action="store_true", help="Output in JSON format")
19
+ p.add_argument("--quiet", action="store_true", help="Suppress info/warnings")
20
+ p.set_defaults(func=run)
21
+
22
+
23
+ def run(args: argparse.Namespace) -> int:
24
+ root = args.root.resolve()
25
+
26
+ if not root.is_dir():
27
+ print(f"Error: Not a directory: {root}", file=sys.stderr)
28
+ return 2
29
+
30
+ skills = find_skills(root)
31
+
32
+ if args.json:
33
+ data = [{"name": s.name, "description": s.description, "path": str(s.path)} for s in skills]
34
+ print(json.dumps(data, indent=2))
35
+ else:
36
+ if not skills:
37
+ print(f"No skills found under {root}")
38
+ else:
39
+ for s in skills:
40
+ print(f"{s.name:<30} {s.path}")
41
+
42
+ if args.add_found and skills:
43
+ added = 0
44
+ for s in skills:
45
+ entry = SkillEntry(
46
+ path=str(s.path),
47
+ name=s.name,
48
+ description=s.description,
49
+ )
50
+ if add_skill(entry):
51
+ added += 1
52
+
53
+ if not args.json and not args.quiet:
54
+ print(f"\nRegistered {added} new skill(s), {len(skills) - added} updated.")
55
+
56
+ return 0
commands/help.py ADDED
@@ -0,0 +1,51 @@
1
+ """`asr help` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+
8
+ def register(subparsers, parser_ref: argparse.ArgumentParser) -> None:
9
+ """Register the help subcommand.
10
+
11
+ Args:
12
+ subparsers: The subparsers object to add to.
13
+ parser_ref: Reference to the main parser for displaying help.
14
+ """
15
+ p = subparsers.add_parser(
16
+ "help",
17
+ help="Show help for a command",
18
+ add_help=False,
19
+ )
20
+ p.add_argument(
21
+ "command",
22
+ nargs="?",
23
+ help="Command to show help for",
24
+ )
25
+ p.set_defaults(func=lambda args: run(args, parser_ref))
26
+
27
+
28
+ def run(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
29
+ """Show help for the specified command or general help."""
30
+ if not args.command:
31
+ parser.print_help()
32
+ return 0
33
+
34
+ # Find the subparser for the given command
35
+ subparsers_action = None
36
+ for action in parser._actions:
37
+ if isinstance(action, argparse._SubParsersAction):
38
+ subparsers_action = action
39
+ break
40
+
41
+ if subparsers_action is None:
42
+ print("Error: No commands available")
43
+ return 1
44
+
45
+ if args.command in subparsers_action.choices:
46
+ subparsers_action.choices[args.command].print_help()
47
+ return 0
48
+ else:
49
+ print(f"Unknown command: {args.command}")
50
+ print(f"\nAvailable commands: {', '.join(sorted(subparsers_action.choices.keys()))}")
51
+ return 1
commands/info.py ADDED
@@ -0,0 +1,152 @@
1
+ """Info command - show detailed information about a skill."""
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from datetime import datetime
7
+
8
+ from manifest import check_manifest, load_manifest
9
+ from registry import load_registry
10
+ from skillcopy.remote import is_remote_source
11
+
12
+
13
+ def run(args: argparse.Namespace) -> int:
14
+ """Show detailed information about a skill.
15
+
16
+ Args:
17
+ args: Parsed command-line arguments
18
+
19
+ Returns:
20
+ Exit code (0 for success, non-zero for errors)
21
+ """
22
+ skill_name = args.skill_name
23
+
24
+ # Load registry to verify skill exists
25
+ entries = load_registry()
26
+ entry = None
27
+ for e in entries:
28
+ if e.name == skill_name:
29
+ entry = e
30
+ break
31
+
32
+ if not entry:
33
+ if not args.quiet:
34
+ print(f"Error: Skill '{skill_name}' not found", file=sys.stderr)
35
+ print("Try: oasr list", file=sys.stderr)
36
+ return 1
37
+
38
+ # Load manifest
39
+ manifest = load_manifest(skill_name)
40
+ if not manifest:
41
+ if not args.quiet:
42
+ print(f"No manifest found for: {skill_name}", file=sys.stderr)
43
+ return 1
44
+
45
+ # Check if remote and show progress indicator
46
+ is_remote = is_remote_source(manifest.source_path)
47
+ if is_remote and not args.quiet and not args.json:
48
+ platform = (
49
+ "GitHub"
50
+ if "github.com" in manifest.source_path
51
+ else "GitLab"
52
+ if "gitlab.com" in manifest.source_path
53
+ else "remote"
54
+ )
55
+ print(f"Checking remote skill status from {platform}...", file=sys.stderr, flush=True)
56
+
57
+ # Check status
58
+ status_result = check_manifest(manifest)
59
+
60
+ # Determine type
61
+ is_remote = is_remote_source(manifest.source_path)
62
+ if is_remote:
63
+ if "github.com" in manifest.source_path:
64
+ skill_type = "Remote (GitHub)"
65
+ elif "gitlab.com" in manifest.source_path:
66
+ skill_type = "Remote (GitLab)"
67
+ else:
68
+ skill_type = "Remote"
69
+ else:
70
+ skill_type = "Local"
71
+
72
+ # Format registered date
73
+ try:
74
+ reg_date = datetime.fromisoformat(manifest.registered_at.replace("Z", "+00:00"))
75
+ reg_date_str = reg_date.strftime("%Y-%m-%d %H:%M:%S UTC")
76
+ except Exception:
77
+ reg_date_str = manifest.registered_at
78
+
79
+ # Prepare data
80
+ info = {
81
+ "name": manifest.name,
82
+ "description": manifest.description,
83
+ "source": manifest.source_path,
84
+ "type": skill_type,
85
+ "status": status_result.status,
86
+ "file_count": len(manifest.files),
87
+ "content_hash": manifest.content_hash,
88
+ "registered_at": reg_date_str,
89
+ }
90
+
91
+ if args.files:
92
+ info["files"] = [{"path": f.path, "hash": f.hash} for f in manifest.files]
93
+
94
+ # Output
95
+ if args.json:
96
+ print(json.dumps(info, indent=2))
97
+ else:
98
+ # Human-readable format
99
+ status_icon = {
100
+ "valid": "✓",
101
+ "modified": "↻",
102
+ "missing": "✗",
103
+ }.get(status_result.status, "?")
104
+
105
+ print(f"\n[{manifest.name}]")
106
+ print("---")
107
+ print(manifest.description)
108
+ print("---")
109
+ print(f"Source: {manifest.source_path}")
110
+ print(f"Type: {skill_type}")
111
+ print(f"Status: {status_icon} {status_result.status.capitalize()}")
112
+ if status_result.message:
113
+ print(f" {status_result.message}")
114
+ print(f"Files: {len(manifest.files)}")
115
+ print(f"Hash: {manifest.content_hash[:20]}...")
116
+ print(f"Registered: {reg_date_str}")
117
+
118
+ if args.files and manifest.files:
119
+ print(f"\nFiles ({len(manifest.files)}):")
120
+ for file_info in manifest.files:
121
+ print(f" - {file_info.path}")
122
+
123
+ print()
124
+
125
+ return 0
126
+
127
+
128
+ def register(subparsers):
129
+ """Register the info command with argparse.
130
+
131
+ Args:
132
+ subparsers: Subparsers object from argparse
133
+ """
134
+ parser = subparsers.add_parser(
135
+ "info",
136
+ help="Show detailed information about a skill",
137
+ description="Display detailed information about a registered skill including "
138
+ "metadata, status, and optionally the list of files.",
139
+ )
140
+
141
+ parser.add_argument(
142
+ "skill_name",
143
+ help="Skill name to show information for",
144
+ )
145
+
146
+ parser.add_argument(
147
+ "--files",
148
+ action="store_true",
149
+ help="Show list of files in the skill",
150
+ )
151
+
152
+ parser.set_defaults(func=run)
commands/list.py ADDED
@@ -0,0 +1,110 @@
1
+ """`asr list` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import textwrap
9
+ from shutil import get_terminal_size
10
+
11
+ from registry import load_registry
12
+
13
+
14
+ def register(subparsers) -> None:
15
+ p = subparsers.add_parser("list", help="List registered skills")
16
+ p.add_argument("--json", action="store_true", help="Output in JSON format")
17
+ p.add_argument("--verbose", "-v", action="store_true", help="Show full paths")
18
+ p.set_defaults(func=run)
19
+
20
+
21
+ def _shorten_path(path: str, max_len: int = 40) -> str:
22
+ """Shorten a path for display, using ~ for home directory."""
23
+ home = os.path.expanduser("~")
24
+ if path.startswith(home):
25
+ path = "~" + path[len(home) :]
26
+
27
+ if len(path) <= max_len:
28
+ return path
29
+
30
+ # Truncate middle of path
31
+ parts = path.split(os.sep)
32
+ if len(parts) <= 2:
33
+ return path[: max_len - 3] + "..."
34
+
35
+ # Keep first and last parts, truncate middle
36
+ result = parts[0] + os.sep + "..." + os.sep + parts[-1]
37
+ if len(result) <= max_len:
38
+ # Try to add more parts from the end
39
+ for i in range(len(parts) - 2, 0, -1):
40
+ candidate = parts[0] + os.sep + "..." + os.sep.join(parts[i:])
41
+ if len(candidate) <= max_len:
42
+ result = candidate
43
+ else:
44
+ break
45
+ return result
46
+
47
+
48
+ def _wrap_description(desc: str, width: int, indent: int = 3) -> str:
49
+ """Wrap description text with proper indentation."""
50
+ if not desc:
51
+ return ""
52
+ prefix = " " * indent
53
+ wrapped = textwrap.fill(
54
+ desc.strip(),
55
+ width=max(20, width - indent),
56
+ initial_indent=prefix,
57
+ subsequent_indent=prefix,
58
+ )
59
+ return wrapped
60
+
61
+
62
+ def run(args: argparse.Namespace) -> int:
63
+ entries = load_registry()
64
+
65
+ if not entries:
66
+ if args.json:
67
+ print("[]")
68
+ else:
69
+ print("No skills registered. Use 'asr add <path>' to register a skill.")
70
+ return 0
71
+
72
+ if args.json:
73
+ data = [{"name": e.name, "description": e.description, "path": e.path} for e in entries]
74
+ print(json.dumps(data, indent=2))
75
+ return 0
76
+
77
+ width = min(100, max(60, get_terminal_size((80, 20)).columns))
78
+ verbose = getattr(args, "verbose", False)
79
+
80
+ # Header
81
+ print(f"\n REGISTERED SKILLS ({len(entries)})\n")
82
+
83
+ # Calculate max name length for alignment
84
+ max_name = max(len(e.name) for e in entries)
85
+ path_width = width - max_name - 10 # Account for formatting
86
+
87
+ for e in sorted(entries, key=lambda x: x.name):
88
+ name = e.name
89
+
90
+ if verbose:
91
+ path_display = e.path
92
+ else:
93
+ path_display = _shorten_path(e.path, max(20, path_width))
94
+
95
+ # Skill header with box drawing
96
+ print(f" ┌─ {name}")
97
+ print(f" │ {path_display}")
98
+
99
+ desc = (e.description or "").strip()
100
+ if desc:
101
+ # Truncate description to one line if too long
102
+ max_desc = width - 6
103
+ if len(desc) > max_desc:
104
+ desc = desc[: max_desc - 3] + "..."
105
+ print(f" └─ {desc}")
106
+ else:
107
+ print(" └─ (no description)")
108
+ print()
109
+
110
+ return 0