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

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

2

3 from __future__ import annotations

4

5 import argparse

6 from pathlib import Path

7

8 from little_loops.logger import Logger

9

10

11 def 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="""

34 Examples:

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

193 def _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