Coverage for little_loops / cli / messages.py: 7%

82 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-03-18 16:18 -0500

1"""ll-messages: Extract user messages from Claude Code session logs.""" 

2 

3from __future__ import annotations 

4 

5import argparse 

6from pathlib import Path 

7 

8from little_loops.logger import Logger 

9 

10 

11def main_messages() -> int: 

12 """Entry point for ll-messages command. 

13 

14 Extract user messages from Claude Code session logs. 

15 

16 Returns: 

17 Exit code (0 = success) 

18 """ 

19 import json 

20 from datetime import datetime 

21 

22 from little_loops.user_messages import ( 

23 CommandRecord, 

24 UserMessage, 

25 extract_commands, 

26 extract_user_messages, 

27 get_project_folder, 

28 ) 

29 

30 parser = argparse.ArgumentParser( 

31 description="Extract user messages from Claude Code logs", 

32 formatter_class=argparse.RawDescriptionHelpFormatter, 

33 epilog=""" 

34Examples: 

35 %(prog)s # Last 100 messages to file 

36 %(prog)s -n 50 # Last 50 messages 

37 %(prog)s --since 2026-01-01 # Messages since date 

38 %(prog)s -o output.jsonl # Custom output path 

39 %(prog)s --stdout # Print to terminal 

40 %(prog)s --include-response-context # Include response metadata 

41 %(prog)s --skip-cli # Exclude CLI commands from output 

42 %(prog)s --commands-only # Extract only CLI commands 

43""", 

44 ) 

45 parser.add_argument( 

46 "-n", 

47 "--limit", 

48 type=int, 

49 default=100, 

50 help="Maximum number of messages to extract (default: 100)", 

51 ) 

52 parser.add_argument( 

53 "--since", 

54 type=str, 

55 help="Only include messages after this date (YYYY-MM-DD or ISO format)", 

56 ) 

57 parser.add_argument( 

58 "-o", 

59 "--output", 

60 type=Path, 

61 help="Output file path (default: .claude/user-messages-{timestamp}.jsonl)", 

62 ) 

63 parser.add_argument( 

64 "--cwd", 

65 type=Path, 

66 help="Working directory to use (default: current directory)", 

67 ) 

68 parser.add_argument( 

69 "--exclude-agents", 

70 action="store_true", 

71 help="Exclude agent session files (agent-*.jsonl)", 

72 ) 

73 parser.add_argument( 

74 "--stdout", 

75 action="store_true", 

76 help="Print messages to stdout instead of writing to file", 

77 ) 

78 parser.add_argument( 

79 "-v", 

80 "--verbose", 

81 action="store_true", 

82 help="Print verbose progress information", 

83 ) 

84 parser.add_argument( 

85 "--include-response-context", 

86 action="store_true", 

87 help="Include metadata from assistant responses (tools used, files modified)", 

88 ) 

89 parser.add_argument( 

90 "--skip-cli", 

91 action="store_true", 

92 help="Exclude CLI commands from output (included by default)", 

93 ) 

94 parser.add_argument( 

95 "--commands-only", 

96 action="store_true", 

97 help="Extract only CLI commands, no user messages", 

98 ) 

99 parser.add_argument( 

100 "--tools", 

101 type=str, 

102 default="Bash", 

103 help="Comma-separated list of tools to extract commands from (default: Bash)", 

104 ) 

105 

106 args = parser.parse_args() 

107 

108 logger = Logger(verbose=args.verbose) 

109 

110 # Parse since date if provided 

111 since = None 

112 if args.since: 

113 try: 

114 # Try ISO format first 

115 since = datetime.fromisoformat(args.since.replace("Z", "+00:00")) 

116 except ValueError: 

117 try: 

118 # Try YYYY-MM-DD format 

119 since = datetime.strptime(args.since, "%Y-%m-%d") 

120 except ValueError: 

121 logger.error(f"Invalid date format: {args.since}") 

122 logger.error("Use YYYY-MM-DD or ISO format") 

123 return 1 

124 

125 # Get project folder 

126 cwd = args.cwd or Path.cwd() 

127 project_folder = get_project_folder(cwd) 

128 

129 if project_folder is None: 

130 logger.error(f"No Claude project folder found for: {cwd}") 

131 logger.error(f"Expected: ~/.claude/projects/{str(cwd).replace('/', '-')}") 

132 return 1 

133 

134 logger.info(f"Project folder: {project_folder}") 

135 logger.info(f"Limit: {args.limit}") 

136 if since: 

137 logger.info(f"Since: {since}") 

138 

139 # Parse tools list 

140 tools_list = [t.strip() for t in args.tools.split(",")] 

141 

142 # Extract data based on flags 

143 messages: list[UserMessage] = [] 

144 commands: list[CommandRecord] = [] 

145 

146 if not args.commands_only: 

147 messages = extract_user_messages( 

148 project_folder=project_folder, 

149 limit=None, # Apply limit after merging 

150 since=since, 

151 include_agent_sessions=not args.exclude_agents, 

152 include_response_context=args.include_response_context, 

153 ) 

154 

155 if not args.skip_cli or args.commands_only: 

156 commands = extract_commands( 

157 project_folder=project_folder, 

158 limit=None, # Apply limit after merging 

159 since=since, 

160 include_agent_sessions=not args.exclude_agents, 

161 tools=tools_list, 

162 ) 

163 

164 if not messages and not commands: 

165 logger.warning("No user messages or commands found") 

166 return 0 

167 

168 # Merge and sort by timestamp 

169 combined: list[UserMessage | CommandRecord] = [] 

170 combined.extend(messages) 

171 combined.extend(commands) 

172 combined.sort(key=lambda x: x.timestamp, reverse=True) 

173 

174 # Apply limit 

175 if args.limit is not None: 

176 combined = combined[: args.limit] 

177 

178 msg_count = len([x for x in combined if isinstance(x, UserMessage)]) 

179 cmd_count = len([x for x in combined if isinstance(x, CommandRecord)]) 

180 logger.info(f"Found {msg_count} messages, {cmd_count} commands") 

181 

182 # Output 

183 if args.stdout: 

184 for item in combined: 

185 print(json.dumps(item.to_dict())) 

186 else: 

187 output_path = _save_combined(combined, args.output) 

188 logger.success(f"Saved {len(combined)} records to: {output_path}") 

189 

190 return 0 

191 

192 

193def _save_combined( 

194 items: list, 

195 output_path: Path | None = None, 

196) -> Path: 

197 """Save combined messages and commands to JSONL file.""" 

198 import json 

199 from datetime import datetime 

200 

201 if output_path is None: 

202 timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") 

203 output_dir = Path.cwd() / ".claude" 

204 output_dir.mkdir(parents=True, exist_ok=True) 

205 output_path = output_dir / f"user-messages-{timestamp}.jsonl" 

206 

207 output_path = Path(output_path) 

208 output_path.parent.mkdir(parents=True, exist_ok=True) 

209 

210 with open(output_path, "w", encoding="utf-8") as f: 

211 for item in items: 

212 f.write(json.dumps(item.to_dict()) + "\n") 

213 

214 return output_path