Coverage for src / osiris_cli / utils.py: 0%

120 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2025-12-28 16:44 +0200

1from __future__ import annotations 

2 

3import json 

4import os 

5import sys 

6import base64 

7import shutil 

8from datetime import datetime 

9from pathlib import Path 

10from typing import Any, Dict, List, Optional 

11 

12from .config import CONFIG_DIR 

13 

14# --- CLIPBOARD UTILS --- 

15 

16def copy_to_clipboard(text: str) -> tuple[bool, str]: 

17 """ 

18 Attempts to copy text to clipboard using multiple methods. 

19 Returns (success: bool, method_used: str). 

20 """ 

21 # 1. Local (Pyperclip / xclip / wl-copy) 

22 # Only try this if we suspect we have access to a clipboard (DISPLAY set or similar) 

23 # But on a VPS via SSH, DISPLAY is usually empty. 

24 # We try it anyway as it's the "standard" way if dependencies exist. 

25 try: 

26 import pyperclip 

27 pyperclip.copy(text) 

28 return True, "System Clipboard" 

29 except Exception: 

30 pass 

31 

32 # 2. Command Line Tools (Fallback if pyperclip failed but tools exist) 

33 # (xclip, wl-copy, pbcopy) - typically pyperclip handles these, but explicit check helps debug 

34 

35 # 3. OSC 52 (Remote / SSH) 

36 # This is the most reliable method for modern terminals (iTerm2, Alacritty, Windows Terminal, WezTerm) 

37 # over SSH. 

38 try: 

39 data = base64.b64encode(text.encode("utf-8")).decode("utf-8") 

40 osc = f"\x1b]52;c;{data}\x07" 

41 

42 # Tmux wrapping 

43 if "tmux" in os.environ.get("TERM", ""): 

44 osc = f"\x1bPtmux;\x1b{osc}\x1b\\" 

45 

46 # Write directly to stdout. In Textual, this might interfere with UI, 

47 # but since it's an escape sequence, it should be invisible if handled by terminal. 

48 # Using sys.__stdout__ to bypass any potential redirections. 

49 stdout = sys.__stdout__ 

50 if stdout: 

51 stdout.write(osc) 

52 stdout.flush() 

53 return True, "OSC 52 (Remote)" 

54 except Exception: 

55 pass 

56 

57 return False, "None" 

58 

59 

60# --- EXISTING UTILS --- 

61 

62def _activity_path() -> Path: 

63 return CONFIG_DIR / "activity.log" 

64 

65 

66def log_activity( 

67 kind: str, 

68 detail: Optional[Dict[str, Any]] = None, 

69) -> None: 

70 """Append a single JSON line to the Osiris activity log.""" 

71 try: 

72 payload: Dict[str, Any] = { 

73 "ts": datetime.utcnow().isoformat() + "Z", 

74 "kind": kind, 

75 } 

76 if detail: 

77 payload["detail"] = detail 

78 path = _activity_path() 

79 path.parent.mkdir(parents=True, exist_ok=True) 

80 with path.open("a", encoding="utf-8") as f: 

81 f.write(json.dumps(payload, ensure_ascii=False) + "\n") 

82 except Exception: 

83 # Logging must never break core flows. 

84 return 

85 

86 

87def read_activity(limit: int = 12) -> List[Dict[str, Any]]: 

88 """Return the most recent activity entries.""" 

89 path = _activity_path() 

90 if not path.exists(): 

91 return [] 

92 try: 

93 with path.open("r", encoding="utf-8") as f: 

94 lines = [line.strip() for line in f if line.strip()] 

95 entries = [] 

96 for line in lines[-limit:]: 

97 try: 

98 entries.append(json.loads(line)) 

99 except Exception: 

100 entries.append({"raw": line}) 

101 return entries 

102 except Exception: 

103 return [] 

104 

105 

106def last_recipe_path() -> Optional[Path]: 

107 """Return the path to the most recent recipe run log.""" 

108 recipe_dir = CONFIG_DIR / "recipes" 

109 if not recipe_dir.exists(): 

110 return None 

111 try: 

112 # Sort by modification time (newest first) or name if timestamped 

113 files = sorted(recipe_dir.glob("*.md"), key=lambda p: p.stat().st_mtime, reverse=True) 

114 return files[0] if files else None 

115 except Exception: 

116 return None 

117 

118def last_recipe_summary() -> Optional[str]: 

119 path = last_recipe_path() 

120 if not path: 

121 return None 

122 return f"{path.name} ({datetime.fromtimestamp(path.stat().st_mtime).isoformat()})" 

123 

124def get_git_status(path: str) -> str | None: 

125 """ 

126 Get the short git status of a file (e.g., 'M', 'A', '??'). 

127 Returns None if clean or error. 

128 """ 

129 import subprocess 

130 try: 

131 # -z for machine readable, --porcelain for stability 

132 res = subprocess.run( 

133 ["git", "status", "--porcelain", path], 

134 capture_output=True, 

135 text=True, 

136 timeout=1 

137 ) 

138 if res.returncode != 0 or not res.stdout: 

139 return None 

140 

141 # Output format is "XY path". We want X or Y. 

142 # e.g. " M src/app.py" -> Modified 

143 # "?? new.py" -> Untracked 

144 line = res.stdout.strip() 

145 if not line: 

146 return None 

147 

148 # First two chars are status 

149 status_chars = line[:2] 

150 if "M" in status_chars: return "M" 

151 if "A" in status_chars: return "A" 

152 if "?" in status_chars: return "?" 

153 if "D" in status_chars: return "D" 

154 return status_chars.strip() 

155 

156 except Exception: 

157 return None 

158 

159# --- OUTPUT / SANITIZATION HELPERS --- 

160 

161def mask_secret(val: Any) -> Any: 

162 try: 

163 # pydantic SecretStr compatibility 

164 if hasattr(val, "get_secret_value"): 

165 return "****" 

166 except Exception: 

167 pass 

168 return val 

169 

170 

171def settings_to_public_dict(obj: Any) -> Dict[str, Any]: 

172 try: 

173 data = obj.model_dump(exclude_none=True) 

174 except Exception: 

175 # Fallback to dict() 

176 try: 

177 data = dict(obj) 

178 except Exception: 

179 return {} 

180 for k, v in list(data.items()): 

181 data[k] = mask_secret(v) 

182 return data 

183 

184 

185def format_for_output(payload: Any, fmt: str = "text") -> str: 

186 """ 

187 Return a string rendered as JSON or text. 

188 - If fmt == json and payload is not a string, serialize as JSON. 

189 - If payload is a string, return it unchanged for text; for json, wrap in an object. 

190 """ 

191 try: 

192 f = (fmt or "text").lower() 

193 except Exception: 

194 f = "text" 

195 if f == "json": 

196 if isinstance(payload, str): 

197 return json.dumps({"message": payload}, indent=2, ensure_ascii=False) 

198 return json.dumps(payload, indent=2, ensure_ascii=False) 

199 # text 

200 if isinstance(payload, (dict, list)): 

201 return json.dumps(payload, indent=2, ensure_ascii=False) 

202 return str(payload)