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 +7 -0
- data/exe/adlog +5 -0
- data/exe/adlog-claude-code-watch +389 -0
- data/lib/adlog/cli/version.rb +5 -0
- data/lib/adlog/cli.rb +329 -0
- metadata +45 -0
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,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
|
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: []
|