oasr 0.5.1__py3-none-any.whl → 0.6.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.
commands/config.py CHANGED
@@ -2,10 +2,20 @@
2
2
 
3
3
  import argparse
4
4
  import sys
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ if sys.version_info >= (3, 11):
9
+ import tomllib
10
+ else:
11
+ import tomli as tomllib
5
12
 
6
13
  from agents import detect_available_agents
7
- from config import CONFIG_FILE, load_config, save_config
14
+ from config import CONFIG_FILE, get_default_config, load_config, save_config
8
15
  from config.schema import validate_agent, validate_profile_reference
16
+ from profiles.builtins import BUILTIN_PROFILES
17
+ from profiles.loader import list_profile_files, load_profiles
18
+ from profiles.summary import format_profile_summary, sorted_profile_names
9
19
 
10
20
 
11
21
  def register(subparsers: argparse._SubParsersAction) -> None:
@@ -50,6 +60,63 @@ def register(subparsers: argparse._SubParsersAction) -> None:
50
60
  )
51
61
  list_parser.set_defaults(func=run_list)
52
62
 
63
+ # config agent
64
+ agent_parser = config_subparsers.add_parser(
65
+ "agent",
66
+ help="Show agent configuration",
67
+ description="Show default agent configuration and availability",
68
+ )
69
+ agent_parser.set_defaults(func=run_agent)
70
+
71
+ # config validation
72
+ validation_parser = config_subparsers.add_parser(
73
+ "validation",
74
+ help="Show validation settings",
75
+ description="Show validation configuration settings",
76
+ )
77
+ validation_parser.set_defaults(func=run_validation)
78
+
79
+ # config adapter
80
+ adapter_parser = config_subparsers.add_parser(
81
+ "adapter",
82
+ help="Show adapter settings",
83
+ description="Show adapter configuration settings",
84
+ )
85
+ adapter_parser.set_defaults(func=run_adapter)
86
+
87
+ # config oasr
88
+ oasr_parser = config_subparsers.add_parser(
89
+ "oasr",
90
+ help="Show core OASR settings",
91
+ description="Show core OASR configuration settings",
92
+ )
93
+ oasr_parser.set_defaults(func=run_oasr)
94
+
95
+ # config profiles
96
+ profiles_parser = config_subparsers.add_parser(
97
+ "profiles",
98
+ help="Show execution profiles",
99
+ description="Show available execution policy profiles",
100
+ )
101
+ profiles_parser.add_argument("--names", action="store_true", help="Output profile names only")
102
+ profiles_parser.set_defaults(func=run_profiles)
103
+
104
+ # config man
105
+ man_parser = config_subparsers.add_parser(
106
+ "man",
107
+ help="Show configuration reference",
108
+ description="Show configuration reference with examples",
109
+ )
110
+ man_parser.set_defaults(func=run_man)
111
+
112
+ # config validate
113
+ validate_parser = config_subparsers.add_parser(
114
+ "validate",
115
+ help="Validate config file",
116
+ description="Validate configuration file structure and values",
117
+ )
118
+ validate_parser.set_defaults(func=run_validate)
119
+
53
120
  # config path
54
121
  path_parser = config_subparsers.add_parser(
55
122
  "path",
@@ -62,43 +129,82 @@ def register(subparsers: argparse._SubParsersAction) -> None:
62
129
  parser.set_defaults(func=lambda args: parser.print_help() or 1)
63
130
 
64
131
 
132
+ def _load_file_config(config_path: Path) -> dict[str, Any]:
133
+ if config_path.exists():
134
+ with open(config_path, "rb") as f:
135
+ data = tomllib.load(f)
136
+ if isinstance(data, dict):
137
+ return data
138
+ return {}
139
+
140
+
141
+ def _parse_key(key: str) -> tuple[str, str]:
142
+ key = key.lower()
143
+ aliases = {
144
+ "agent": ("agent", "default"),
145
+ "profile": ("oasr", "default_profile"),
146
+ }
147
+ if key in aliases:
148
+ return aliases[key]
149
+ if "." in key:
150
+ parts = key.split(".", 1)
151
+ if len(parts) != 2:
152
+ raise ValueError(f"Invalid key '{key}'. Use format 'section.field' or 'agent'")
153
+ return parts[0], parts[1]
154
+ raise ValueError(f"Invalid key '{key}'. Use format 'section.field' or 'agent'")
155
+
156
+
157
+ def _format_value(value: Any) -> str:
158
+ if isinstance(value, bool):
159
+ return "true" if value else "false"
160
+ if isinstance(value, list):
161
+ return ", ".join(str(item) for item in value)
162
+ return str(value)
163
+
164
+
165
+ def _parse_bool(value: str, field: str) -> bool:
166
+ value_lower = value.lower()
167
+ if value_lower in ("true", "1", "yes", "on"):
168
+ return True
169
+ if value_lower in ("false", "0", "no", "off"):
170
+ return False
171
+ raise ValueError(f"'{field}' must be a boolean")
172
+
173
+
65
174
  def run_set(args: argparse.Namespace) -> int:
66
175
  """Set a configuration value with validation."""
67
176
  key = args.key.lower()
68
177
  value = args.value
69
178
  force = getattr(args, "force", False)
70
179
 
71
- # Parse key (support dotted notation like "validation.strict")
72
- if "." in key:
73
- parts = key.split(".", 1)
74
- if len(parts) != 2:
75
- print(f"Error: Invalid key '{key}'. Use format 'section.field' or 'agent'", file=sys.stderr)
76
- return 1
77
- section, field = parts
78
- elif key == "agent":
79
- # Special case: bare "agent" means "agent.default"
80
- section, field = "agent", "default"
81
- else:
82
- print(f"Error: Invalid key '{key}'. Use format 'section.field' or 'agent'", file=sys.stderr)
180
+ try:
181
+ section, field = _parse_key(key)
182
+ except ValueError as exc:
183
+ print(f"Error: {exc}", file=sys.stderr)
83
184
  return 1
84
185
 
85
186
  # Type coercion based on field
86
187
  original_value = value
87
- if field == "strict":
88
- value = value.lower() in ("true", "1", "yes", "on")
89
- elif field == "reference_max_lines":
90
- try:
188
+ try:
189
+ if field in ("strict", "completions"):
190
+ value = _parse_bool(value, field)
191
+ elif field == "reference_max_lines":
91
192
  value = int(value)
92
193
  if value < 1:
93
- print(f"Error: '{field}' must be a positive integer", file=sys.stderr)
94
- return 1
95
- except ValueError:
96
- print(f"Error: '{field}' must be an integer", file=sys.stderr)
97
- return 1
194
+ raise ValueError(f"'{field}' must be a positive integer")
195
+ elif field == "default_targets":
196
+ value = [item.strip() for item in value.split(",") if item.strip()]
197
+ elif field == "default_profile":
198
+ value = value.strip()
199
+ except ValueError as exc:
200
+ print(f"Error: {exc}", file=sys.stderr)
201
+ return 1
98
202
 
99
203
  # Load config
100
204
  config_path = getattr(args, "config", None)
205
+ config_file_path = config_path or CONFIG_FILE
101
206
  config = load_config(config_path=config_path)
207
+ file_config = _load_file_config(config_file_path)
102
208
 
103
209
  # Validate before setting (unless --force)
104
210
  if not force:
@@ -115,18 +221,18 @@ def run_set(args: argparse.Namespace) -> int:
115
221
  is_valid, error_msg = validate_profile_reference(value, config)
116
222
  if not is_valid:
117
223
  print(f"Error: {error_msg}", file=sys.stderr)
118
- print("\nCreate the profile in ~/.oasr/config.toml first, or use:", file=sys.stderr)
224
+ print("\nCreate the profile in ~/.oasr/config.toml or ~/.oasr/profile/, or use:", file=sys.stderr)
119
225
  print(f" oasr config set --force oasr.default_profile {value}", file=sys.stderr)
120
226
  return 1
121
227
 
122
228
  # Set the value
123
- if section not in config:
124
- config[section] = {}
229
+ if section not in file_config:
230
+ file_config[section] = {}
125
231
 
126
- config[section][field] = value
232
+ file_config[section][field] = value
127
233
 
128
234
  try:
129
- save_config(config, config_path=config_path)
235
+ save_config(file_config, config_path=config_file_path)
130
236
 
131
237
  # Show confirmation
132
238
  if section == "agent" and field == "default":
@@ -137,6 +243,8 @@ def run_set(args: argparse.Namespace) -> int:
137
243
  else:
138
244
  print(f"✓ Default agent set to: {value}")
139
245
  print(f" Warning: '{value}' binary not found in PATH. Install it to use this agent.", file=sys.stderr)
246
+ elif section == "oasr" and field == "default_profile":
247
+ print(f"✓ Default profile set to: {value}")
140
248
  else:
141
249
  print(f"✓ Set {section}.{field} = {original_value}")
142
250
 
@@ -152,18 +260,20 @@ def run_get(args: argparse.Namespace) -> int:
152
260
 
153
261
  config = load_config(args.config if hasattr(args, "config") else None)
154
262
 
155
- if key == "agent":
156
- agent = config["agent"].get("default")
157
- if agent:
158
- print(agent)
159
- else:
160
- print("No default agent configured", file=sys.stderr)
161
- return 1
162
- return 0
163
- else:
164
- print(f"Error: Unsupported config key '{key}'. Only 'agent' is supported.", file=sys.stderr)
263
+ try:
264
+ section, field = _parse_key(key)
265
+ except ValueError as exc:
266
+ print(f"Error: {exc}", file=sys.stderr)
165
267
  return 1
166
268
 
269
+ value = config.get(section, {}).get(field)
270
+ if value is None:
271
+ print(f"Config value not set: {section}.{field}", file=sys.stderr)
272
+ return 1
273
+
274
+ print(_format_value(value))
275
+ return 0
276
+
167
277
 
168
278
  def run_list(args: argparse.Namespace) -> int:
169
279
  """List all configuration."""
@@ -202,9 +312,146 @@ def run_list(args: argparse.Namespace) -> int:
202
312
  print(f" default_targets = {config['adapter']['default_targets']}")
203
313
  print()
204
314
 
315
+ # OASR section
316
+ print(" [oasr]")
317
+ print(f" default_profile = {config['oasr']['default_profile']}")
318
+ print(f" completions = {_format_value(config['oasr']['completions'])}")
319
+ print()
320
+
321
+ # Profiles section
322
+ print(" [profiles]")
323
+ profiles = config.get("profiles", {})
324
+ for name in sorted_profile_names(profiles):
325
+ profile = profiles[name]
326
+ summary = format_profile_summary(name, profile)
327
+ print(f" {summary}")
328
+ print()
329
+
330
+ return 0
331
+
332
+
333
+ def run_agent(args: argparse.Namespace) -> int:
334
+ config = load_config(args.config if hasattr(args, "config") else None)
335
+ agent = config["agent"].get("default")
336
+ available = detect_available_agents()
337
+
338
+ print("Agent configuration:")
339
+ print(f" Default: {agent or '(not set)'}")
340
+ print()
341
+ if available:
342
+ print("Available agents:")
343
+ for name in sorted(available):
344
+ print(f" ✓ {name}")
345
+ else:
346
+ print("Available agents: (none detected)")
347
+ return 0
348
+
349
+
350
+ def run_validation(args: argparse.Namespace) -> int:
351
+ config = load_config(args.config if hasattr(args, "config") else None)
352
+ validation = config.get("validation", {})
353
+ print("Validation settings:")
354
+ print(f" reference_max_lines: {validation.get('reference_max_lines')}")
355
+ print(f" strict: {validation.get('strict')}")
356
+ return 0
357
+
358
+
359
+ def run_adapter(args: argparse.Namespace) -> int:
360
+ config = load_config(args.config if hasattr(args, "config") else None)
361
+ adapter = config.get("adapter", {})
362
+ targets = adapter.get("default_targets", [])
363
+ print("Adapter settings:")
364
+ print(f" default_targets: {', '.join(targets)}")
365
+ return 0
366
+
367
+
368
+ def run_oasr(args: argparse.Namespace) -> int:
369
+ config = load_config(args.config if hasattr(args, "config") else None)
370
+ oasr = config.get("oasr", {})
371
+ print("OASR settings:")
372
+ print(f" default_profile: {oasr.get('default_profile')}")
373
+ print(f" completions: {_format_value(oasr.get('completions'))}")
205
374
  return 0
206
375
 
207
376
 
377
+ def run_profiles(args: argparse.Namespace) -> int:
378
+ config = load_config(args.config if hasattr(args, "config") else None)
379
+ profiles = config.get("profiles", {})
380
+ names = sorted_profile_names(profiles)
381
+
382
+ if args.names:
383
+ print("\n".join(names))
384
+ return 0
385
+
386
+ print("Profiles:")
387
+ for name in names:
388
+ summary = format_profile_summary(name, profiles[name])
389
+ suffix = " (built-in)" if name in BUILTIN_PROFILES else ""
390
+ print(f" {summary}{suffix}")
391
+ print("\nProfile files: ~/.oasr/profile/*.toml (inline config overrides)")
392
+ return 0
393
+
394
+
395
+ def run_man(args: argparse.Namespace) -> int:
396
+ print("OASR configuration reference")
397
+ print()
398
+ print("Quick reference:")
399
+ print(" oasr config set <key> <value>")
400
+ print(" oasr config get <key>")
401
+ print(" oasr config list")
402
+ print(" oasr config path")
403
+ print(" oasr config validate")
404
+ print()
405
+ print("Common keys:")
406
+ print(" agent")
407
+ print(" oasr.default_profile")
408
+ print(" oasr.completions")
409
+ print(" validation.strict")
410
+ print(" validation.reference_max_lines")
411
+ print(" adapter.default_targets")
412
+ print()
413
+ print("Examples:")
414
+ print(" oasr config set agent codex")
415
+ print(" oasr config set oasr.default_profile dev")
416
+ print(" oasr config set validation.strict true")
417
+ print(" oasr config set adapter.default_targets cursor,windsurf")
418
+ print()
419
+ print("Profiles:")
420
+ print(" - Built-ins: safe, strict, dev, unsafe")
421
+ print(" - Inline: [profiles.<name>] in config.toml")
422
+ print(" - Files: ~/.oasr/profile/<name>.toml (body keys only)")
423
+ print()
424
+ print("Docs: docs/commands/CONFIG.md and docs/configuration/README.md")
425
+ return 0
426
+
427
+
428
+ def run_validate(args: argparse.Namespace) -> int:
429
+ config_path = args.config if hasattr(args, "config") and args.config else CONFIG_FILE
430
+ if not config_path.exists():
431
+ defaults = get_default_config()
432
+ try:
433
+ save_config(defaults, config_path=config_path)
434
+ print(f"✓ Created default config at {config_path}")
435
+ except ValueError as exc:
436
+ print(f"Error: {exc}", file=sys.stderr)
437
+ return 1
438
+
439
+ try:
440
+ file_config = _load_file_config(config_path)
441
+ if not isinstance(file_config, dict):
442
+ raise ValueError("Config file must contain a table")
443
+ inline_profiles = file_config.get("profiles", {})
444
+ _ = load_profiles(inline_profiles=inline_profiles if isinstance(inline_profiles, dict) else {})
445
+ save_config(file_config, config_path=config_path)
446
+ print(f"✓ Config valid: {config_path}")
447
+ if list_profile_files():
448
+ print("✓ Profile files loaded")
449
+ return 0
450
+ except ValueError as exc:
451
+ print(f"Error: {exc}", file=sys.stderr)
452
+ return 1
453
+
454
+
208
455
  def run_path(args: argparse.Namespace) -> int:
209
456
  """Show config file path."""
210
457
  if hasattr(args, "config") and args.config:
commands/exec.py CHANGED
@@ -43,6 +43,11 @@ def setup_parser(subparsers):
43
43
  "--agent",
44
44
  help="Override the default agent (codex, copilot, claude, opencode)",
45
45
  )
46
+ parser.add_argument(
47
+ "--unsafe",
48
+ action="store_true",
49
+ help="Pass unsafe mode flags to the agent CLI (use with caution)",
50
+ )
46
51
  parser.add_argument(
47
52
  "--profile",
48
53
  help="Execution policy profile to use (default: from config)",
@@ -161,7 +166,22 @@ def run(args: argparse.Namespace) -> int:
161
166
  print("━" * 60, file=sys.stderr)
162
167
 
163
168
  try:
164
- result = driver.execute(skill_content, user_prompt)
169
+ extra_args = []
170
+ if getattr(args, "unsafe", False):
171
+ if agent_name == "codex":
172
+ extra_args.append("--skip-git-repo-check")
173
+ elif agent_name == "claude":
174
+ extra_args.append("--dangerously-skip-permissions")
175
+ else:
176
+ print(
177
+ f"Warning: --unsafe is not supported for agent '{agent_name}'.",
178
+ file=sys.stderr,
179
+ )
180
+ print(
181
+ "See agent docs for trusted directory or permission configuration.",
182
+ file=sys.stderr,
183
+ )
184
+ result = driver.execute(skill_content, user_prompt, extra_args=extra_args or None)
165
185
  # CompletedProcess has returncode attribute (0 = success)
166
186
  # Output was already streamed to stdout since capture_output=False
167
187
  return result.returncode
commands/profile.py ADDED
@@ -0,0 +1,84 @@
1
+ """`oasr profile` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import questionary
10
+
11
+ from config import CONFIG_FILE, load_config, save_config
12
+ from profiles import BUILTIN_PROFILES, format_profile_summary, sorted_profile_names
13
+
14
+
15
+ def register(subparsers: argparse._SubParsersAction) -> None:
16
+ parser = subparsers.add_parser(
17
+ "profile",
18
+ help="List or set execution profiles",
19
+ description="List and select execution policy profiles",
20
+ )
21
+ parser.add_argument("name", nargs="?", help="Profile name to set as default")
22
+ parser.set_defaults(func=run)
23
+
24
+
25
+ def _print_profiles(profiles: dict[str, dict[str, object]], current: str) -> None:
26
+ names = sorted_profile_names(profiles)
27
+ print("Profiles:")
28
+ for name in names:
29
+ summary = format_profile_summary(name, profiles[name])
30
+ suffix = []
31
+ if name == current:
32
+ suffix.append("current")
33
+ if name in BUILTIN_PROFILES:
34
+ suffix.append("built-in")
35
+ suffix_text = f" ({', '.join(suffix)})" if suffix else ""
36
+ print(f" {summary}{suffix_text}")
37
+
38
+
39
+ def _select_profile(names: list[str], current: str) -> str | None:
40
+ default_choice = current if current in names else (names[0] if names else None)
41
+ if not default_choice:
42
+ return None
43
+ response = questionary.select(
44
+ "Select a default profile:",
45
+ choices=names,
46
+ default=default_choice,
47
+ ).ask()
48
+ return response
49
+
50
+
51
+ def _set_default_profile(config_path: Path, profile_name: str) -> int:
52
+ config = load_config(config_path=config_path)
53
+ profiles = config.get("profiles", {})
54
+ if profile_name not in profiles:
55
+ print(f"Error: Profile '{profile_name}' not found.", file=sys.stderr)
56
+ return 1
57
+
58
+ config["oasr"]["default_profile"] = profile_name
59
+ save_config(config, config_path=config_path)
60
+ print(f"✓ Default profile set to: {profile_name}")
61
+ return 0
62
+
63
+
64
+ def run(args: argparse.Namespace) -> int:
65
+ config_path = CONFIG_FILE
66
+ config = load_config(config_path=config_path)
67
+ profiles = config.get("profiles", {})
68
+ current = config.get("oasr", {}).get("default_profile", "safe")
69
+
70
+ if args.name:
71
+ return _set_default_profile(config_path, args.name)
72
+
73
+ if not sys.stdout.isatty() or not sys.stdin.isatty():
74
+ _print_profiles(profiles, current)
75
+ print("\nTip: run `oasr profile <name>` to set the default profile.", file=sys.stderr)
76
+ return 0
77
+
78
+ names = sorted_profile_names(profiles)
79
+ choice = _select_profile(names, current)
80
+ if not choice:
81
+ print("No profiles available.", file=sys.stderr)
82
+ return 1
83
+
84
+ return _set_default_profile(config_path, choice)
commands/update.py CHANGED
@@ -6,6 +6,8 @@ import argparse
6
6
  import json
7
7
  import subprocess
8
8
  import sys
9
+ import urllib.request
10
+ from importlib import metadata
9
11
  from pathlib import Path
10
12
 
11
13
 
@@ -215,6 +217,51 @@ def get_stats(repo_path: Path, old_commit: str, new_commit: str) -> dict:
215
217
  return stats
216
218
 
217
219
 
220
+ def get_installed_version(package: str = "oasr") -> str | None:
221
+ """Get installed package version."""
222
+ try:
223
+ return metadata.version(package)
224
+ except metadata.PackageNotFoundError:
225
+ return None
226
+
227
+
228
+ def get_latest_pypi_version(package: str = "oasr") -> str | None:
229
+ """Fetch the latest version from PyPI."""
230
+ try:
231
+ with urllib.request.urlopen(f"https://pypi.org/pypi/{package}/json", timeout=5) as response:
232
+ data = json.load(response)
233
+ return data.get("info", {}).get("version")
234
+ except Exception:
235
+ return None
236
+
237
+
238
+ def upgrade_from_pypi(package: str = "oasr") -> tuple[bool, str]:
239
+ """Upgrade ASR using uv or pip."""
240
+ commands = [
241
+ ["uv", "pip", "install", "--upgrade", package],
242
+ [sys.executable, "-m", "pip", "install", "--upgrade", package],
243
+ ]
244
+ last_error = ""
245
+
246
+ for cmd in commands:
247
+ try:
248
+ result = subprocess.run(
249
+ cmd,
250
+ capture_output=True,
251
+ text=True,
252
+ timeout=60,
253
+ )
254
+ if result.returncode == 0:
255
+ runner = "uv" if cmd[0] == "uv" else "pip"
256
+ return True, f"Updated with {runner}"
257
+ last_error = result.stderr.strip() or result.stdout.strip()
258
+ except (subprocess.TimeoutExpired, FileNotFoundError):
259
+ last_error = "Update timed out" if isinstance(sys.exc_info()[1], subprocess.TimeoutExpired) else last_error
260
+ continue
261
+
262
+ return False, last_error or "Failed to update with pip"
263
+
264
+
218
265
  def reinstall_asr(repo_path: Path) -> tuple[bool, str]:
219
266
  """Reinstall ASR using uv or pip.
220
267
 
@@ -293,10 +340,29 @@ def run(args: argparse.Namespace) -> int:
293
340
 
294
341
  if not repo_path:
295
342
  if args.json:
296
- print(json.dumps({"success": False, "error": "Could not find ASR git repository"}))
297
- else:
298
- print("✗ Could not find ASR git repository", file=sys.stderr)
299
- print(" Make sure ASR is installed from git (git clone + pip install -e .)", file=sys.stderr)
343
+ print(
344
+ json.dumps(
345
+ {
346
+ "success": False,
347
+ "error": "Could not find ASR git repository",
348
+ "hint": "Install from git or use pip to update",
349
+ }
350
+ )
351
+ )
352
+ return 1
353
+
354
+ print("✗ Could not find ASR git repository", file=sys.stderr)
355
+ print(" This command updates git installs only.", file=sys.stderr)
356
+ print(" To update PyPI installs:", file=sys.stderr)
357
+ print(" pip install --upgrade oasr", file=sys.stderr)
358
+
359
+ latest_version = get_latest_pypi_version()
360
+ installed_version = get_installed_version()
361
+ if latest_version and installed_version and latest_version != installed_version:
362
+ print(
363
+ f"\nUpdate available: {installed_version} → {latest_version}",
364
+ file=sys.stderr,
365
+ )
300
366
  return 1
301
367
 
302
368
  if not args.quiet and not args.json:
@@ -306,8 +372,10 @@ def run(args: argparse.Namespace) -> int:
306
372
  if not (repo_path / ".git").exists():
307
373
  if args.json:
308
374
  print(json.dumps({"success": False, "error": "Not a git repository"}))
309
- else:
310
- print(f"✗ {repo_path} is not a git repository", file=sys.stderr)
375
+ return 1
376
+ print(f"✗ {repo_path} is not a git repository", file=sys.stderr)
377
+ print(" Use pip to update PyPI installs:", file=sys.stderr)
378
+ print(" pip install --upgrade oasr", file=sys.stderr)
311
379
  return 1
312
380
 
313
381
  # Get remote URL
@@ -348,10 +416,24 @@ def run(args: argparse.Namespace) -> int:
348
416
 
349
417
  # Check if already up to date
350
418
  if message == "already_up_to_date":
419
+ latest_version = get_latest_pypi_version()
420
+ installed_version = get_installed_version()
421
+
351
422
  if args.json:
352
- print(json.dumps({"success": True, "updated": False, "message": "Already up to date"}))
423
+ payload = {"success": True, "updated": False, "message": "Already up to date"}
424
+ if latest_version and installed_version:
425
+ payload["installed_version"] = installed_version
426
+ payload["latest_version"] = latest_version
427
+ payload["pypi_update_available"] = latest_version != installed_version
428
+ print(json.dumps(payload))
353
429
  else:
354
430
  print("✓ Already up to date")
431
+ if latest_version and installed_version and latest_version != installed_version:
432
+ print(
433
+ f"\nPyPI update available: {installed_version} → {latest_version}",
434
+ file=sys.stderr,
435
+ )
436
+ print("Run: pip install --upgrade oasr", file=sys.stderr)
355
437
  return 0
356
438
 
357
439
  # Get new commit
@@ -0,0 +1 @@
1
+ """Shell completion scripts for OASR."""