adlog-cli 0.1.0

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b2df74409bd05c5dade76e7a839135bf87d620789bb070f9978821d3e8ecf098
4
+ data.tar.gz: 2bde4eb999917d624821e0a4f7ea48883fe46731ed175c5370d20a0c48d4e361
5
+ SHA512:
6
+ metadata.gz: f0a0f5d7248039bbddf270c9185e811e9349cef5e876f0bb05f9b249ded2e2d327a17d5dd480e868020adf25197b6c31feabd007bde47e21cac2115b3af7dd25
7
+ data.tar.gz: 21abdf89682af28a2dad0843242fbf02b16074e3372472757d405519b0aaa07689aca6ac6c7ccf7afb2d0d76add119dfce376929804a77e7f2fe2d74a8e57a0e
data/exe/adlog ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative "../lib/adlog/cli"
4
+
5
+ exit Adlog::CLI::Runner.new.run(ARGV)
@@ -0,0 +1,389 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # adlog-claude-code-watch: Claude Code セッションログを inotify で監視し、
5
+ # 差分を adlog に append で送信する。
6
+ #
7
+ # SessionStart hook から呼ばれる想定:
8
+ # stdin から JSON { session_id, transcript_path, cwd } を受け取り、
9
+ # fork してデーモン化、inotify + pidfd で監視。
10
+ # Claude プロセス終了で自動停止。
11
+
12
+ require "json"
13
+ require "fiddle"
14
+ require "fiddle/import"
15
+ require_relative "../lib/adlog/cli"
16
+
17
+ # ============================================================
18
+ # Linux syscall wrappers
19
+ # ============================================================
20
+
21
+ module LinuxSys
22
+ extend Fiddle::Importer
23
+ dlload "libc.so.6"
24
+ extern "int inotify_init()"
25
+ extern "int inotify_add_watch(int, const char*, unsigned int)"
26
+ extern "int close(int)"
27
+ extern "int read(int, void*, unsigned int)"
28
+ extern "long syscall(long, ...)"
29
+
30
+ # pidfd_open(2) — Linux 5.3+、glibc ラッパー不要で syscall 直接呼び出し
31
+ SYS_PIDFD_OPEN = 434 # x86_64
32
+
33
+ def self.pidfd_open(pid, flags)
34
+ syscall(SYS_PIDFD_OPEN, pid, flags).to_i
35
+ end
36
+
37
+ IN_MODIFY = 0x00000002
38
+ IN_CREATE = 0x00000100
39
+ IN_MOVED_TO = 0x00000080
40
+ end
41
+
42
+ # ============================================================
43
+ # Claude Code セッション監視デーモン
44
+ # ============================================================
45
+
46
+ class SessionWatcher
47
+ SELECT_TIMEOUT = 30 # 秒
48
+
49
+ def initialize(db_option:, session_id:, transcript_path:, cwd:, model:, source:, target_pid:)
50
+ @db_option = db_option
51
+ @session_id = session_id
52
+ @transcript_path = transcript_path
53
+ @cwd = cwd
54
+ @model = model
55
+ @source = source
56
+ @target_pid = target_pid
57
+ @last_sent_size = 0
58
+ @tags = build_tags
59
+ end
60
+
61
+ def run
62
+ daemonize
63
+ setup_pid_file
64
+ setup_signals
65
+
66
+ $0 = "adlog-claude-code-watch: #{@session_id[0..7]}"
67
+
68
+ @pidfd = open_pidfd
69
+ @inotify_fd, @inotify_io = setup_inotify
70
+ at_exit { cleanup_fds }
71
+
72
+ send_initial_diff
73
+ main_loop
74
+ end
75
+
76
+ private
77
+
78
+ # ---- デーモン化・セットアップ ----
79
+
80
+ def daemonize
81
+ exit 0 if fork
82
+ Process.setsid
83
+ $stdin.reopen("/dev/null")
84
+ $stdout.reopen("/dev/null")
85
+ $stderr.reopen("/dev/null")
86
+ end
87
+
88
+ def setup_pid_file
89
+ @pid_file = File.join(ENV["TMPDIR"] || "/tmp", "adlog-cc-watch-#{@session_id}.pid")
90
+ File.write(@pid_file, Process.pid.to_s)
91
+ at_exit { File.delete(@pid_file) if File.exist?(@pid_file) }
92
+ end
93
+
94
+ def setup_signals
95
+ trap("TERM") { exit 0 }
96
+ trap("INT") { exit 0 }
97
+ end
98
+
99
+ # ---- pidfd / inotify ----
100
+
101
+ def open_pidfd
102
+ fd = LinuxSys.pidfd_open(@target_pid, 0)
103
+ at_exit { LinuxSys.close(fd) if fd >= 0 } if fd >= 0
104
+ fd
105
+ rescue
106
+ -1
107
+ end
108
+
109
+ def setup_inotify
110
+ fd = LinuxSys.inotify_init
111
+ if fd < 0
112
+ $stderr.reopen("/dev/stderr") rescue nil
113
+ $stderr.puts "adlog-claude-code-watch: inotify_init failed"
114
+ exit 1
115
+ end
116
+
117
+ LinuxSys.inotify_add_watch(
118
+ fd, File.dirname(@transcript_path),
119
+ LinuxSys::IN_MODIFY | LinuxSys::IN_CREATE | LinuxSys::IN_MOVED_TO
120
+ )
121
+
122
+ io = IO.for_fd(fd, autoclose: false)
123
+ [fd, io]
124
+ end
125
+
126
+ def cleanup_fds
127
+ LinuxSys.close(@pidfd) if @pidfd && @pidfd >= 0
128
+ LinuxSys.close(@inotify_fd) if @inotify_fd
129
+ end
130
+
131
+ # ---- プロセス生存確認 ----
132
+
133
+ def target_alive?
134
+ Process.kill(0, @target_pid)
135
+ true
136
+ rescue Errno::ESRCH
137
+ false
138
+ rescue Errno::EPERM
139
+ # 権限不足でもプロセスは存在する
140
+ true
141
+ end
142
+
143
+ # ---- メインループ ----
144
+
145
+ def send_initial_diff
146
+ @last_sent_size = send_diff
147
+ end
148
+
149
+ def main_loop
150
+ pidfd_io = @pidfd >= 0 ? IO.for_fd(@pidfd, autoclose: false) : nil
151
+ watch_fds = [@inotify_io]
152
+ watch_fds << pidfd_io if pidfd_io
153
+ buf = Fiddle::Pointer.malloc(4096)
154
+
155
+ loop do
156
+ readable = IO.select(watch_fds, nil, nil, SELECT_TIMEOUT)
157
+
158
+ if readable
159
+ ready = readable[0]
160
+
161
+ # pidfd が readable → Claude プロセス終了
162
+ if pidfd_io && ready.include?(pidfd_io)
163
+ finish
164
+ return
165
+ end
166
+
167
+ # inotify イベント → ファイル差分送信
168
+ if ready.include?(@inotify_io)
169
+ LinuxSys.read(@inotify_fd, buf, 4096)
170
+ @last_sent_size = send_diff
171
+ end
172
+ else
173
+ # タイムアウト: 差分チェック + プロセス生存確認
174
+ @last_sent_size = send_diff
175
+ unless target_alive?
176
+ finish
177
+ return
178
+ end
179
+ end
180
+ rescue => e
181
+ # プロセス生存確認をリトライ前にも行う
182
+ unless target_alive?
183
+ finish
184
+ return
185
+ end
186
+ sleep 1
187
+ end
188
+ end
189
+
190
+ # 終了処理: 最終差分送信 + token 集計タグ追加
191
+ def finish
192
+ @last_sent_size = send_diff
193
+
194
+ total_in, total_out = count_tokens
195
+ if total_in > 0 || total_out > 0
196
+ final_tags = @tags + ["tokens_in:#{total_in}", "tokens_out:#{total_out}"]
197
+ send_tags(tags: final_tags)
198
+ end
199
+ end
200
+
201
+ # ---- タグ構築 ----
202
+
203
+ def build_tags
204
+ tags = ["session:#{@session_id}"]
205
+ tags << "cwd:#{@cwd}" if @cwd
206
+ tags << "model:#{@model}" if @model
207
+ tags << "source:#{@source}" if @source
208
+ tags
209
+ end
210
+
211
+ # ---- adlog 送信 ----
212
+
213
+ def send_diff
214
+ return @last_sent_size unless File.exist?(@transcript_path)
215
+
216
+ current_size = File.size(@transcript_path)
217
+ return @last_sent_size if current_size <= @last_sent_size
218
+
219
+ diff = File.open(@transcript_path, "rb") do |f|
220
+ f.seek(@last_sent_size)
221
+ f.read
222
+ end
223
+
224
+ return @last_sent_size if diff.nil? || diff.empty?
225
+
226
+ dir_name = @cwd ? File.basename(@cwd) : nil
227
+ title = [dir_name, @session_id[0..7]].compact.join(" ")
228
+
229
+ argv = [
230
+ "--append", @session_id,
231
+ "--type", "claude_code",
232
+ "--title", title,
233
+ "-q"
234
+ ]
235
+ @tags.each { |t| argv.push("--tag", t) }
236
+ argv.push("--db", @db_option) if @db_option
237
+
238
+ if run_cli(argv, input: diff)
239
+ current_size
240
+ else
241
+ @last_sent_size
242
+ end
243
+ rescue
244
+ @last_sent_size
245
+ end
246
+
247
+ # タグのみ更新(body は空白1文字で append)
248
+ def send_tags(tags:)
249
+ argv = ["--append", @session_id, "--type", "claude_code", "-q"]
250
+ tags.each { |t| argv.push("--tag", t) }
251
+ argv.push("--db", @db_option) if @db_option
252
+
253
+ run_cli(argv, input: " ")
254
+ rescue
255
+ nil
256
+ end
257
+
258
+ # CLI を fork して実行
259
+ def run_cli(argv, input:)
260
+ runner = Adlog::CLI::Runner.new
261
+ rd, wr = IO.pipe
262
+ wr.binmode
263
+ wr.write(input)
264
+ wr.close
265
+
266
+ pid = fork do
267
+ $stdin.reopen(rd)
268
+ rd.close
269
+ exit runner.run(argv)
270
+ end
271
+ rd.close
272
+ _, status = Process.waitpid2(pid)
273
+ status.exitstatus == 0
274
+ end
275
+
276
+ # ---- token 集計 ----
277
+
278
+ def count_tokens
279
+ total_in = 0
280
+ total_out = 0
281
+ File.foreach(@transcript_path) do |line|
282
+ obj = JSON.parse(line)
283
+ usage = obj.dig("message", "usage")
284
+ if usage
285
+ total_in += usage["input_tokens"].to_i
286
+ total_out += usage["output_tokens"].to_i
287
+ end
288
+ rescue JSON::ParserError
289
+ next
290
+ end
291
+ [total_in, total_out]
292
+ rescue
293
+ [0, 0]
294
+ end
295
+ end
296
+
297
+ # ============================================================
298
+ # エントリポイント
299
+ # ============================================================
300
+
301
+ HELP_TEXT = <<~HELP
302
+ Usage: adlog-claude-code-watch [--db @user/name]
303
+
304
+ Watch a Claude Code session transcript and incrementally send diffs to adlog.
305
+
306
+ This command is designed to be called as a Claude Code SessionStart hook.
307
+ It reads JSON from stdin (provided by Claude Code) containing:
308
+ - session_id (required) Unique session identifier
309
+ - transcript_path (required) Path to the session transcript file
310
+ - cwd (optional) Working directory of the session
311
+
312
+ After reading the input, it forks a background daemon that:
313
+ 1. Monitors the transcript file for changes via inotify
314
+ 2. Sends new content to adlog incrementally using append mode
315
+ 3. Automatically exits when the Claude Code process terminates
316
+
317
+ Options:
318
+ --db @user/name Target database to send logs to (e.g. @ko1/chats)
319
+ -h, --help Show this help message
320
+
321
+ Setup:
322
+ Add the following to ~/.claude/settings.json:
323
+
324
+ {
325
+ "hooks": {
326
+ "SessionStart": [
327
+ {
328
+ "matcher": "",
329
+ "hooks": [
330
+ {
331
+ "type": "command",
332
+ "command": "adlog-claude-code-watch --db @user/name"
333
+ }
334
+ ]
335
+ }
336
+ ]
337
+ }
338
+ }
339
+
340
+ Requirements:
341
+ - Linux (uses inotify and pidfd syscalls)
342
+ - adlog CLI gem installed
343
+ HELP
344
+
345
+ # オプション引数: --db @user/name
346
+ db_option = nil
347
+ args = ARGV.dup
348
+ while arg = args.shift
349
+ if arg == "--db" && args.first
350
+ db_option = args.shift
351
+ elsif arg == "-h" || arg == "--help"
352
+ puts HELP_TEXT
353
+ exit 0
354
+ end
355
+ end
356
+
357
+ input = JSON.parse($stdin.read)
358
+ session_id = input["session_id"]
359
+ transcript_path = input["transcript_path"]
360
+ cwd = input["cwd"]
361
+ model = input["model"]
362
+ source = input["source"]
363
+
364
+ unless session_id && transcript_path
365
+ $stderr.puts "adlog-claude-code-watch: session_id and transcript_path required (stdin JSON)"
366
+ exit 1
367
+ end
368
+
369
+ # 親プロセス(Claude Code)の PID を取得
370
+ # hook は sh -c で実行されるので、grandparent を探す
371
+ target_pid = Process.ppid
372
+ begin
373
+ comm = File.read("/proc/#{target_pid}/comm").strip
374
+ if comm =~ /\A(sh|bash|dash|zsh)\z/
375
+ stat = File.read("/proc/#{target_pid}/stat")
376
+ target_pid = $1.to_i if stat =~ /\)\s+\S+\s+(\d+)/
377
+ end
378
+ rescue
379
+ end
380
+
381
+ SessionWatcher.new(
382
+ db_option: db_option,
383
+ session_id: session_id,
384
+ transcript_path: transcript_path,
385
+ cwd: cwd,
386
+ model: model,
387
+ source: source,
388
+ target_pid: target_pid
389
+ ).run
@@ -0,0 +1,5 @@
1
+ module Adlog
2
+ module CLI
3
+ VERSION = "0.1.0"
4
+ end
5
+ end
data/lib/adlog/cli.rb ADDED
@@ -0,0 +1,329 @@
1
+ require "net/http"
2
+ require "uri"
3
+ require "json"
4
+ require "optparse"
5
+ require_relative "cli/version"
6
+
7
+ module Adlog
8
+ module CLI
9
+ class UsageError < StandardError; end
10
+ class AuthError < StandardError; end
11
+ class HttpError < StandardError; end
12
+
13
+ class Runner
14
+ EXIT_OK = 0
15
+ EXIT_USAGE = 1
16
+ EXIT_HTTP = 2
17
+ EXIT_AUTH = 3
18
+
19
+ def run(argv)
20
+ options = { tags: [] }
21
+ files = parse_options!(argv, options)
22
+
23
+ auth = resolve_auth(options)
24
+ scope = resolve_scope(options, auth)
25
+ entries = build_entries(files, options)
26
+
27
+ raise UsageError, "No input provided" if entries.empty?
28
+
29
+ base_url = options[:url] || default_url
30
+
31
+ entries.each do |entry|
32
+ if options[:dry_run]
33
+ print_dry_run(base_url, entry, auth, scope, options)
34
+ else
35
+ response = post_ingest(
36
+ base_url: base_url,
37
+ token: auth[:token],
38
+ scope: scope,
39
+ title: entry[:title],
40
+ type: options[:type],
41
+ tags: options[:tags],
42
+ key: options[:key],
43
+ ts: entry[:ts],
44
+ body: entry[:body],
45
+ timeout: options[:timeout] || 30,
46
+ verbose: options[:verbose]
47
+ )
48
+
49
+ if response.is_a?(Net::HTTPSuccess)
50
+ unless options[:quiet]
51
+ payload = parse_json_response(response)
52
+ print_entry_url(base_url, scope, payload)
53
+ end
54
+ else
55
+ body = truncate(response.body, 200)
56
+ $stderr.puts "Error #{response.code}: #{body}"
57
+ return EXIT_HTTP
58
+ end
59
+ end
60
+ end
61
+
62
+ EXIT_OK
63
+ rescue UsageError => e
64
+ $stderr.puts "Usage error: #{e.message}"
65
+ EXIT_USAGE
66
+ rescue AuthError => e
67
+ $stderr.puts "Auth error: #{e.message}"
68
+ EXIT_AUTH
69
+ rescue HttpError => e
70
+ $stderr.puts "HTTP error: #{e.message}"
71
+ EXIT_HTTP
72
+ end
73
+
74
+ private
75
+
76
+ # --- Option parsing ---
77
+
78
+ def parse_options!(argv, options)
79
+ parser = OptionParser.new do |o|
80
+ o.banner = "Usage: adlog [OPTIONS] [FILES...]"
81
+ o.separator ""
82
+
83
+ o.on("--url URL", "Server URL (default: ADLOG_URL or http://localhost:3000)") { |v| options[:url] = v }
84
+ o.on("--db SCOPE", "DB scope @user/name") { |v| options[:db] = v }
85
+ o.on("--gha", "Use GitHub Actions OIDC auth") { options[:gha] = true }
86
+ o.on("--token TOKEN", "Access token (fallback: ADLOG_TOKEN)") { |v| options[:token] = v }
87
+ o.on("--title TITLE", "Entry title") { |v| options[:title] = v }
88
+ o.on("--type TYPE", "Entry type (e.g. text/ruby, gha, claude_code)") { |v| options[:type] = v }
89
+ o.on("--tag TAG", "Tag (repeatable, e.g. --tag repo:ruby/ruby --tag status:ok)") { |v| options[:tags] << v }
90
+ o.on("--append KEY", "Append to existing entry with KEY, or create new") { |v| options[:key] = v }
91
+ o.on("--mtime", "Use file mtime as entry timestamp") { options[:mtime] = true }
92
+ o.on("--timeout SEC", Integer, "HTTP timeout seconds (default: 30)") { |v| options[:timeout] = v }
93
+ o.on("--dry-run", "Print request summary, don't send") { options[:dry_run] = true }
94
+ o.on("--verbose", "Print request/response details") { options[:verbose] = true }
95
+ o.on("-q", "--quiet", "Suppress output on success") { options[:quiet] = true }
96
+ o.on("-h", "--help", "Show help") { puts o; exit EXIT_OK }
97
+ end
98
+ parser.parse!(argv)
99
+ argv # remaining args are files
100
+ rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e
101
+ raise UsageError, e.message
102
+ end
103
+
104
+ # --- Authentication ---
105
+
106
+ def resolve_auth(options)
107
+ if options[:gha]
108
+ token = fetch_oidc_token(ENV.fetch("ADLOG_OIDC_AUDIENCE", "adlog"))
109
+ { type: "oidc", token: token }
110
+ elsif options[:token]
111
+ parse_token_option(options)
112
+ elsif ENV["ADLOG_TOKEN"]
113
+ { type: "token", token: ENV["ADLOG_TOKEN"] }
114
+ elsif ENV["ADLOG_TOKENS"]
115
+ if options[:db]
116
+ token = select_token_from_env(options[:db])
117
+ # --db foo → @$USER/foo でも探す
118
+ if token.nil? && !options[:db].start_with?("@")
119
+ login = ENV["ADLOG_LOGIN"] || ENV["USER"]
120
+ if login
121
+ expanded = "@#{login}/#{options[:db]}"
122
+ token = select_token_from_env(expanded)
123
+ options[:db] = expanded if token
124
+ end
125
+ end
126
+ raise AuthError, "No matching token in ADLOG_TOKENS for #{options[:db]}" unless token
127
+ { type: "token", token: token }
128
+ else
129
+ db, token = first_token_from_env
130
+ raise AuthError, "ADLOG_TOKENS is empty" unless db
131
+ options[:db] = db
132
+ { type: "token", token: token }
133
+ end
134
+ else
135
+ raise AuthError, "No authentication provided. Use --token, --gha, or set ADLOG_TOKEN"
136
+ end
137
+ end
138
+
139
+ def parse_token_option(options)
140
+ val = options[:token]
141
+ # db:token format (last colon splits token from db)
142
+ if val.start_with?("@") && val.include?(":")
143
+ last_colon = val.rindex(":")
144
+ db = val[0...last_colon]
145
+ token = val[(last_colon + 1)..]
146
+ raise UsageError, "--db and db:token format cannot both be specified" if options[:db]
147
+ options[:db] = db
148
+ { type: "token", token: token }
149
+ else
150
+ { type: "token", token: val }
151
+ end
152
+ end
153
+
154
+ def select_token_from_env(db_name)
155
+ parse_tokens_env.each do |key, val|
156
+ return val if key == db_name
157
+ end
158
+ nil
159
+ end
160
+
161
+ def first_token_from_env
162
+ parse_tokens_env.first
163
+ end
164
+
165
+ def parse_tokens_env
166
+ ENV["ADLOG_TOKENS"].split(",").filter_map do |pair|
167
+ last_colon = pair.rindex(":")
168
+ next unless last_colon
169
+ key = pair[0...last_colon].strip
170
+ val = pair[(last_colon + 1)..].strip
171
+ [key, val]
172
+ end
173
+ end
174
+
175
+ def fetch_oidc_token(audience)
176
+ request_url = ENV["ACTIONS_ID_TOKEN_REQUEST_URL"]
177
+ request_token = ENV["ACTIONS_ID_TOKEN_REQUEST_TOKEN"]
178
+ raise AuthError, "ACTIONS_ID_TOKEN_REQUEST_URL not set (not running in GitHub Actions?)" unless request_url
179
+ raise AuthError, "ACTIONS_ID_TOKEN_REQUEST_TOKEN not set" unless request_token
180
+
181
+ url = append_audience(request_url, audience)
182
+ uri = URI.parse(url)
183
+ http = Net::HTTP.new(uri.host, uri.port)
184
+ http.use_ssl = uri.scheme == "https"
185
+
186
+ req = Net::HTTP::Get.new(uri)
187
+ req["Authorization"] = "Bearer #{request_token}"
188
+ resp = http.request(req)
189
+
190
+ raise AuthError, "OIDC token fetch failed: #{resp.code}" unless resp.is_a?(Net::HTTPSuccess)
191
+
192
+ data = JSON.parse(resp.body)
193
+ data["value"] || raise(AuthError, "OIDC response missing 'value'")
194
+ end
195
+
196
+ def append_audience(request_url, audience)
197
+ sep = request_url.include?("?") ? "&" : "?"
198
+ "#{request_url}#{sep}audience=#{audience}"
199
+ end
200
+
201
+ # --- Scope ---
202
+
203
+ def resolve_scope(options, auth)
204
+ if options[:db]
205
+ options[:db]
206
+ elsif auth[:type] == "oidc"
207
+ nil # server derives from OIDC claim
208
+ else
209
+ login = ENV["ADLOG_LOGIN"] || ENV["USER"]
210
+ raise UsageError, "Cannot determine DB scope. Use --db or set ADLOG_LOGIN" unless login
211
+ "@#{login}/inbox"
212
+ end
213
+ end
214
+
215
+ # --- Entry building ---
216
+
217
+ def build_entries(files, options)
218
+ if files.empty?
219
+ if $stdin.tty?
220
+ raise UsageError, "No files specified and stdin is a terminal. Pipe data or pass file arguments."
221
+ end
222
+ body = $stdin.binmode.read
223
+ [{
224
+ body: body,
225
+ title: options[:title] || (options[:key] ? nil : "<STDIN>"),
226
+ ts: nil
227
+ }]
228
+ else
229
+ files.map do |path|
230
+ raise UsageError, "File not found: #{path}" unless File.exist?(path)
231
+ body = File.binread(path)
232
+ ts = options[:mtime] ? (File.mtime(path).to_f * 1000).to_i : nil
233
+ {
234
+ body: body,
235
+ title: options[:title] || File.basename(path),
236
+ ts: ts
237
+ }
238
+ end
239
+ end
240
+ end
241
+
242
+ # --- HTTP ---
243
+
244
+ def post_ingest(base_url:, token:, scope:, title:, type:, tags:, key:, ts:, body:, timeout:, verbose:)
245
+ uri = build_ingest_uri(base_url, scope: scope, title: title, type: type, tags: tags, key: key, ts: ts)
246
+
247
+ if verbose
248
+ $stderr.puts "POST #{uri}"
249
+ $stderr.puts "Content-Length: #{body.bytesize}"
250
+ end
251
+
252
+ http = Net::HTTP.new(uri.host, uri.port)
253
+ http.use_ssl = uri.scheme == "https"
254
+ http.read_timeout = timeout
255
+ http.open_timeout = timeout
256
+
257
+ req = Net::HTTP::Post.new(uri)
258
+ req["Authorization"] = "Bearer #{token}"
259
+ req["Content-Type"] = "text/plain"
260
+ req.body = body
261
+
262
+ resp = http.request(req)
263
+
264
+ if verbose
265
+ $stderr.puts "Response: #{resp.code}"
266
+ $stderr.puts resp.body
267
+ end
268
+
269
+ resp
270
+ end
271
+
272
+ def build_ingest_uri(base_url, scope:, title:, type:, tags:, key:, ts:)
273
+ uri = URI.parse("#{base_url}/api/ingest")
274
+ params = {}
275
+ params["db"] = scope if scope
276
+ params["title"] = title if title
277
+ params["type"] = type if type
278
+ params["tags"] = tags.join(",") if tags && !tags.empty?
279
+ params["append"] = key if key
280
+ params["ts"] = ts.to_s if ts
281
+ uri.query = URI.encode_www_form(params) unless params.empty?
282
+ uri
283
+ end
284
+
285
+ # --- Output ---
286
+
287
+ def print_entry_url(base_url, scope, payload)
288
+ db = payload["db"] || scope
289
+ id = payload["id"]
290
+ if db
291
+ # @user/name -> /@user/name/entries/:id
292
+ puts "#{base_url}/#{db}/entries/#{id}"
293
+ else
294
+ puts "#{base_url}/entries/#{id}"
295
+ end
296
+ end
297
+
298
+ def print_dry_run(base_url, entry, auth, scope, options)
299
+ puts JSON.pretty_generate({
300
+ url: build_ingest_uri(base_url, scope: scope, title: entry[:title], type: options[:type], tags: options[:tags], key: options[:key], ts: entry[:ts]).to_s,
301
+ auth: auth[:type],
302
+ db: scope,
303
+ title: entry[:title],
304
+ type: options[:type],
305
+ tags: options[:tags],
306
+ size_bytes: entry[:body].bytesize,
307
+ ts: entry[:ts]
308
+ })
309
+ end
310
+
311
+ def parse_json_response(response)
312
+ JSON.parse(response.body)
313
+ rescue JSON::ParserError
314
+ {}
315
+ end
316
+
317
+ # --- Helpers ---
318
+
319
+ def default_url
320
+ ENV.fetch("ADLOG_URL", "https://log.atdot.net")
321
+ end
322
+
323
+ def truncate(str, max)
324
+ return str if str.nil? || str.length <= max
325
+ str[0...max] + "..."
326
+ end
327
+ end
328
+ end
329
+ end
metadata ADDED
@@ -0,0 +1,45 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: adlog-cli
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - ko1
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Upload text, logs, and conversations to adlog from the command line or
13
+ GitHub Actions.
14
+ executables:
15
+ - adlog
16
+ - adlog-claude-code-watch
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - exe/adlog
21
+ - exe/adlog-claude-code-watch
22
+ - lib/adlog/cli.rb
23
+ - lib/adlog/cli/version.rb
24
+ homepage: https://github.com/ko1/adlog
25
+ licenses:
26
+ - MIT
27
+ metadata: {}
28
+ rdoc_options: []
29
+ require_paths:
30
+ - lib
31
+ required_ruby_version: !ruby/object:Gem::Requirement
32
+ requirements:
33
+ - - ">="
34
+ - !ruby/object:Gem::Version
35
+ version: '3.0'
36
+ required_rubygems_version: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ requirements: []
42
+ rubygems_version: 4.0.6
43
+ specification_version: 4
44
+ summary: CLI for adlog - private searchable text store
45
+ test_files: []