@larktask/aamp-feishu-task-agent 0.1.0-dev.171

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.
package/README.md ADDED
@@ -0,0 +1,153 @@
1
+ # aamp-feishu-task-agent
2
+
3
+ One-click manager for binding local Codex/Cursor agents to user-owned Feishu
4
+ Bots and running the corresponding Task bridges.
5
+
6
+ ## Install and bind
7
+
8
+ Run the standalone one-click command. Its existing Node.js/npm dependency
9
+ installation flow is unchanged:
10
+
11
+ ```bash
12
+ npx -y --package @larktask/aamp-feishu-task-agent@dev \
13
+ feishu-task-agent install
14
+ ```
15
+
16
+ `install` first collects local Agent and Feishu Bot choices until the user
17
+ chooses not to continue. No Bridge is started while choices are being
18
+ collected. The same Agent may be bound to multiple Bots, while a Bot can only
19
+ be selected once. After selection, one Agent Bridge group is prepared and each
20
+ pair is started serially with a real one-time ACP pairing. A successful first
21
+ Feishu Bridge start is kept as the final running process instead of being
22
+ stopped and started a second time. The successfully completed bindings then
23
+ atomically replace the new-flow configuration.
24
+
25
+ The package installs the short command `feishu-task-agent`. Running that short
26
+ command without arguments shows help. Running the standalone Bootstrap without
27
+ arguments is equivalent to `install`.
28
+
29
+ Only `codex` and `cursor` are supported by this flow. `--agent codex|cursor`
30
+ fixes the Agent for every new binding in that command instead of prompting.
31
+ The Task Agent flow only supports the Online environment. Environment-switch
32
+ arguments are not supported.
33
+
34
+ ## Commands
35
+
36
+ ```bash
37
+ feishu-task-agent install
38
+ feishu-task-agent start
39
+ feishu-task-agent list
40
+ feishu-task-agent add
41
+ feishu-task-agent remove
42
+ feishu-task-agent update
43
+ feishu-task-agent help
44
+ ```
45
+
46
+ - `install` collects one or more pairs, then serially pairs and starts them.
47
+ The initial successful Bridge processes remain running.
48
+ - `start` multi-selects saved pairs. Selecting `全部` takes precedence over any
49
+ other selection. If no pair is configured, the terminal prints the full
50
+ install command.
51
+ - `list` prints saved Agent-Bot pairs and never prints App Secrets.
52
+ - `add` completes Bot selection/creation and lark-cli authorization, then saves
53
+ each pair as pending. It does not acquire an Agent lease, perform ACP pairing,
54
+ or start any Bridge. The first subsequent `start` completes pairing, creates
55
+ runtime configuration, and keeps the selected Bridges running.
56
+ - `remove` multi-selects pairs, with a `全部` option. It only removes saved
57
+ pairing records; Bridges that are already running are not stopped.
58
+ - `update` refreshes the installed short command and package.
59
+
60
+ Single-choice and confirmation menus use `↑`/`↓` to move and Enter to confirm.
61
+ For `start` and `remove`, use `↑`/`↓` to move, Space to select multiple items,
62
+ and Enter to confirm the selection.
63
+
64
+ Bridge startup is serial. A failure is printed and recorded, then the next pair
65
+ is attempted. The command exits as a startup failure only when every selected
66
+ pair fails. Within one command invocation, all Bots for the same Agent/AAMP
67
+ host reuse one ACP Bridge process. Independent invocations do not attach to an
68
+ existing process; an Agent lease prevents competing runtimes from being
69
+ started for the same Agent identity.
70
+
71
+ Only `install` and `start` may launch Bridges. Before either command enters its
72
+ interactive flow, it acquires one global runtime-session lease and also checks
73
+ for Agent leases left by an older Task Agent version. If another live Task
74
+ Agent runtime exists, the command exits and asks the user to stop the previous
75
+ terminal with `Ctrl+C`. `add`, `list`, and `remove` never acquire this runtime
76
+ lease and never start a Bridge.
77
+
78
+ ## Configuration and compatibility
79
+
80
+ The new flow uses a dedicated configuration:
81
+
82
+ ```text
83
+ ~/.aamp/feishu-task-agent/bindings-v1.json
84
+ ```
85
+
86
+ It does not read, migrate, overwrite, or delete the legacy Feishu task profile
87
+ configuration. Existing users enter the new binding flow again. Runtime files
88
+ are isolated under:
89
+
90
+ ```text
91
+ ~/.aamp/feishu-task-agent/runtime-v1/
92
+ ```
93
+
94
+ The configuration directory is mode `0700` and `bindings-v1.json` is mode
95
+ `0600`. The App Secret is intentionally stored as plaintext so a saved pair can
96
+ be started again without asking for credentials. Treat this file as a local
97
+ credential and do not share it. Bridge-specific derived configuration is kept
98
+ inside the same protected new-flow runtime directory.
99
+
100
+ Bindings saved by `add` use the `pending` state and do not contain Agent or
101
+ Feishu Bridge runtime identities. After their first successful `start`, the
102
+ same records are atomically updated to `ready` with the generated runtime
103
+ metadata. Existing ready records without an explicit state remain compatible.
104
+
105
+ Saved bindings whose environment is not Online are incompatible with this
106
+ version. They remain visible to `list` and can be deleted with `remove`, but
107
+ `start` skips them with an explicit error and continues with the next binding.
108
+
109
+ If a saved `lark-cli` profile is missing, `start` recreates it from the stored
110
+ App ID/App Secret and resumes user authorization. If the persisted Agent or
111
+ Feishu Bridge mailbox identity is missing or has changed, startup rejects that
112
+ pair and asks the user to bind it again instead of reporting a false success.
113
+ The same Online `LARKSUITE_CLI_CONFIG_DIR` is passed to Feishu Bridge, Agent
114
+ Bridge, and the local Codex/Cursor process so task execution resolves the exact
115
+ profile created during binding.
116
+
117
+ ## Local logs and diagnostics
118
+
119
+ Each command creates a run under:
120
+
121
+ ```text
122
+ ~/.aamp/logs/runs/<timestamp>-<pid>/
123
+ ```
124
+
125
+ Multi-Bot runs use flat, unique component log files so the existing `aamp-logs`
126
+ command can find them:
127
+
128
+ ```text
129
+ one-click.log
130
+ feishu-register.<random>
131
+ acp-bridge-<host-hash>.jsonl
132
+ feishu-bridge-<binding-id>-install.jsonl
133
+ feishu-bridge-<binding-id>-add.jsonl
134
+ feishu-bridge-<binding-id>-start.jsonl
135
+ errors.jsonl
136
+ manifest.json
137
+ ```
138
+
139
+ The manifest and terminal output never include App Secrets. Component output is
140
+ redacted before it is written by the Controller. Verbose Feishu registration
141
+ details (SDK metadata, scopes, events, and raw SDK output) are written to a
142
+ private per-run registration log instead of flooding the interactive terminal.
143
+
144
+ Useful diagnostics commands:
145
+
146
+ ```bash
147
+ ~/.aamp/bin/aamp-logs collect --run-dir <run log directory>
148
+ ~/.aamp/bin/aamp-logs collect --latest
149
+ ~/.aamp/bin/aamp-logs collect --task-id <Task ID>
150
+ ~/.aamp/bin/aamp-logs collect --task-guid <Feishu task guid>
151
+ ~/.aamp/bin/aamp-logs list-runs
152
+ ~/.aamp/bin/aamp-logs tail -f
153
+ ```
@@ -0,0 +1,523 @@
1
+ #!/usr/bin/env node
2
+ import { execFileSync, spawn } from 'node:child_process'
3
+ import {
4
+ existsSync,
5
+ mkdirSync,
6
+ mkdtempSync,
7
+ readFileSync,
8
+ readdirSync,
9
+ rmSync,
10
+ statSync,
11
+ writeFileSync,
12
+ } from 'node:fs'
13
+ import os from 'node:os'
14
+ import path from 'node:path'
15
+
16
+ const SENSITIVE_KEY_PATTERN = [
17
+ 'secret',
18
+ 'token',
19
+ 'password',
20
+ 'authorization',
21
+ 'cookie',
22
+ 'credential',
23
+ 'appSecret',
24
+ 'smtpPassword',
25
+ 'mailboxToken',
26
+ 'access_token',
27
+ 'device_code',
28
+ 'user_code',
29
+ 'app_secret',
30
+ ].join('|')
31
+
32
+ const TEXT_LOG_EXTENSIONS = new Set(['.json', '.jsonl', '.log', '.txt'])
33
+ const FEISHU_TASK_ID_PREFIX = 'feishu-task-'
34
+
35
+ function usage(exitCode = 0) {
36
+ const stream = exitCode === 0 ? process.stdout : process.stderr
37
+ stream.write(`Usage:
38
+ aamp-logs --version
39
+ aamp-logs collect --task-id <task-id>
40
+ aamp-logs collect --task-guid <task-guid>
41
+ aamp-logs collect --run-dir <run-dir>
42
+ aamp-logs collect --latest
43
+ aamp-logs collect --since <duration>
44
+ aamp-logs list-runs
45
+ aamp-logs tail --task-id <task-id>
46
+ aamp-logs tail --task-guid <task-guid>
47
+ aamp-logs tail -f [--task-id <task-id>|--task-guid <task-guid>|<task-id>]
48
+
49
+ Options:
50
+ --log-dir <dir> Override AAMP log root. Defaults to AAMP_LOG_DIR or ~/.aamp/logs.
51
+ --include-content Include full matching run logs instead of matching fragments only.
52
+ -f, --follow Follow new log lines in real time. Without a selector, follows the latest run.
53
+ -h, --help Show this help.
54
+
55
+ Collect creates the local .tar.gz bundle on your Desktop.
56
+ `)
57
+ process.exit(exitCode)
58
+ }
59
+
60
+ function packageVersion() {
61
+ const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
62
+ if (typeof packageJson.version !== 'string' || packageJson.version.length === 0) {
63
+ throw new Error('Task-agent package version is unavailable')
64
+ }
65
+ return packageJson.version
66
+ }
67
+
68
+ function parseArgs(argv) {
69
+ const [command, ...rest] = argv
70
+ if (!command || command === '-h' || command === '--help') usage(0)
71
+
72
+ const options = { command }
73
+ const booleanFlags = new Set(['--latest', '--follow'])
74
+ for (let i = 0; i < rest.length; i += 1) {
75
+ const arg = rest[i]
76
+ if (arg === '-h' || arg === '--help') usage(0)
77
+ if (arg === '-f') {
78
+ options.follow = true
79
+ continue
80
+ }
81
+ if (arg === '--include-content' || booleanFlags.has(arg)) {
82
+ options[arg.slice(2).replace(/-/g, '_')] = true
83
+ continue
84
+ }
85
+ if (arg.startsWith('--')) {
86
+ const value = rest[i + 1]
87
+ if (!value || value.startsWith('--')) {
88
+ throw new Error(`Missing value for ${arg}`)
89
+ }
90
+ options[arg.slice(2).replace(/-/g, '_')] = value
91
+ i += 1
92
+ continue
93
+ }
94
+ if (command === 'tail' && !options.task_id && !options.task_guid) {
95
+ options.task_id = arg
96
+ continue
97
+ }
98
+ throw new Error(`Unknown argument: ${arg}`)
99
+ }
100
+ return options
101
+ }
102
+
103
+ function homeLogsDir() {
104
+ return path.join(os.homedir(), '.aamp', 'logs')
105
+ }
106
+
107
+ function resolveLogRoot(options) {
108
+ return path.resolve(options.log_dir || process.env.AAMP_LOG_DIR || homeLogsDir())
109
+ }
110
+
111
+ function listRunDirs(logRoot) {
112
+ const runsDir = path.join(logRoot, 'runs')
113
+ if (!existsSync(runsDir)) return []
114
+ return readdirSync(runsDir)
115
+ .map((name) => path.join(runsDir, name))
116
+ .filter((entry) => statSync(entry).isDirectory())
117
+ .sort((left, right) => path.basename(left).localeCompare(path.basename(right)))
118
+ }
119
+
120
+ function latestRun(logRoot) {
121
+ const runs = listRunDirs(logRoot)
122
+ return runs.at(-1)
123
+ }
124
+
125
+ function resolveRunDir(logRoot, value) {
126
+ const runDir = path.isAbsolute(value)
127
+ ? path.resolve(value)
128
+ : path.join(logRoot, 'runs', value)
129
+ if (!existsSync(runDir) || !statSync(runDir).isDirectory()) {
130
+ throw new Error(`Run directory not found: ${value}`)
131
+ }
132
+ return runDir
133
+ }
134
+
135
+ function parseDurationMs(value) {
136
+ const match = /^(\d+)(ms|s|m|h|d)?$/.exec(String(value).trim())
137
+ if (!match) throw new Error(`Invalid duration: ${value}`)
138
+ const amount = Number(match[1])
139
+ const unit = match[2] || 'ms'
140
+ const multipliers = {
141
+ ms: 1,
142
+ s: 1000,
143
+ m: 60 * 1000,
144
+ h: 60 * 60 * 1000,
145
+ d: 24 * 60 * 60 * 1000,
146
+ }
147
+ return amount * multipliers[unit]
148
+ }
149
+
150
+ function runTimestamp(runDir) {
151
+ const name = path.basename(runDir)
152
+ const match = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})/.exec(name)
153
+ if (match) {
154
+ const [, year, month, day, hour, minute, second] = match
155
+ return new Date(`${year}-${month}-${day}T${hour}:${minute}:${second}`).getTime()
156
+ }
157
+ return statSync(runDir).mtimeMs
158
+ }
159
+
160
+ function textFiles(runDir) {
161
+ return readdirSync(runDir)
162
+ .map((name) => path.join(runDir, name))
163
+ .filter((file) => {
164
+ const stat = statSync(file)
165
+ return stat.isFile() && TEXT_LOG_EXTENSIONS.has(path.extname(file))
166
+ })
167
+ .sort((left, right) => path.basename(left).localeCompare(path.basename(right)))
168
+ }
169
+
170
+ function readLines(file) {
171
+ return readFileSync(file, 'utf8').split(/\r?\n/).filter((line) => line.length > 0)
172
+ }
173
+
174
+ function lineHasAny(line, values) {
175
+ return values.some((value) => value && line.includes(value))
176
+ }
177
+
178
+ function redactText(input) {
179
+ const key = SENSITIVE_KEY_PATTERN
180
+ return String(input)
181
+ .replace(new RegExp(`("(?:[^"]*(?:${key})[^"]*)"\\s*:\\s*)"[^"]*"`, 'gi'), '$1"<redacted>"')
182
+ .replace(new RegExp(`\\b((?:${key})\\s*=)\\S+`, 'gi'), '$1<redacted>')
183
+ .replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/-]+=*/gi, '$1 <redacted>')
184
+ .replace(new RegExp(`([?&](?:${key})=)[^&\\s]+`, 'gi'), '$1<redacted>')
185
+ }
186
+
187
+ function parseJsonLine(line) {
188
+ try {
189
+ return JSON.parse(line)
190
+ } catch {
191
+ return undefined
192
+ }
193
+ }
194
+
195
+ function extractTaskIdsFromLine(line, parsed) {
196
+ const taskIds = new Set()
197
+ for (const key of ['task_id', 'taskId', 'aampTaskId', 'aamp_task']) {
198
+ if (typeof parsed?.[key] === 'string' && parsed[key]) {
199
+ taskIds.add(parsed[key])
200
+ }
201
+ }
202
+
203
+ const patterns = [
204
+ /"(?:task_id|taskId|aampTaskId|aamp_task)"\s*:\s*"([^"]+)"/g,
205
+ /\b(?:task_id|taskId|aampTaskId|aamp_task)=([A-Za-z0-9._:-]+)/g,
206
+ /\bTask ID:\s*([A-Za-z0-9._:-]+)/g,
207
+ /\b(feishu-task-[A-Za-z0-9._:-]+)/g,
208
+ ]
209
+ for (const pattern of patterns) {
210
+ for (const match of line.matchAll(pattern)) {
211
+ if (match[1]) taskIds.add(match[1])
212
+ }
213
+ }
214
+ return [...taskIds]
215
+ }
216
+
217
+ function taskIdPrefixForGuid(taskGuid) {
218
+ return `${FEISHU_TASK_ID_PREFIX}${taskGuid}-`
219
+ }
220
+
221
+ function collectTaskIdsForGuid(runDirs, taskGuid) {
222
+ const taskIds = new Set()
223
+ const taskIdPrefix = taskIdPrefixForGuid(taskGuid)
224
+ for (const runDir of runDirs) {
225
+ for (const file of textFiles(runDir)) {
226
+ for (const line of readLines(file)) {
227
+ const parsed = parseJsonLine(line)
228
+ const lineMatchesGuid = parsed?.task_guid === taskGuid
229
+ || parsed?.taskGuid === taskGuid
230
+ || line.includes(taskIdPrefix)
231
+ || line.includes(taskGuid)
232
+ if (lineMatchesGuid) {
233
+ for (const taskId of extractTaskIdsFromLine(line, parsed)) {
234
+ if (taskId.startsWith(taskIdPrefix)) taskIds.add(taskId)
235
+ }
236
+ }
237
+ }
238
+ }
239
+ }
240
+ return [...taskIds]
241
+ }
242
+
243
+ function runContainsAny(runDir, values) {
244
+ return textFiles(runDir).some((file) => readLines(file).some((line) => lineHasAny(line, values)))
245
+ }
246
+
247
+ function selectRuns(logRoot, options, matchValues) {
248
+ const runs = listRunDirs(logRoot)
249
+ if (options.run_dir) {
250
+ return [resolveRunDir(logRoot, options.run_dir)]
251
+ }
252
+ if (options.latest) {
253
+ const run = latestRun(logRoot)
254
+ return run ? [run] : []
255
+ }
256
+ if (options.since) {
257
+ const cutoff = Date.now() - parseDurationMs(options.since)
258
+ return runs.filter((runDir) => runTimestamp(runDir) >= cutoff)
259
+ }
260
+ if (matchValues.length > 0) {
261
+ return runs.filter((runDir) => runContainsAny(runDir, matchValues))
262
+ }
263
+ return []
264
+ }
265
+
266
+ function ensureArchiveDir() {
267
+ const archiveDir = path.join(os.homedir(), 'Desktop')
268
+ mkdirSync(archiveDir, { recursive: true })
269
+ return archiveDir
270
+ }
271
+
272
+ function archiveStamp() {
273
+ return new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z')
274
+ }
275
+
276
+ function writeReadme(file, options, runDirs, taskIds) {
277
+ const taskIdPrefix = options.task_guid ? taskIdPrefixForGuid(options.task_guid) : undefined
278
+ writeFileSync(file, [
279
+ 'AAMP local logs bundle',
280
+ '',
281
+ `Created at: ${new Date().toISOString()}`,
282
+ `Command: ${options.command}`,
283
+ taskIds.length ? `Task IDs: ${taskIds.join(', ')}` : undefined,
284
+ taskIdPrefix ? `Task ID prefix: ${taskIdPrefix}` : undefined,
285
+ options.task_guid ? `Task GUID: ${options.task_guid}` : undefined,
286
+ options.latest ? 'Selection: latest run' : undefined,
287
+ options.since ? `Selection: since ${options.since}` : undefined,
288
+ options.run_dir ? `Selection: run dir ${options.run_dir}` : undefined,
289
+ `Runs: ${runDirs.map((runDir) => path.basename(runDir)).join(', ') || '(none)'}`,
290
+ '',
291
+ 'Sensitive values are redacted before packaging. This bundle is local-only and was not uploaded by aamp-logs.',
292
+ '',
293
+ ].filter(Boolean).join('\n'))
294
+ }
295
+
296
+ function writeBundleManifest(file, options, runDirs, taskIds, matchValues) {
297
+ const manifest = {
298
+ schema: 'aamp.local_logs.bundle.v1',
299
+ created_at: new Date().toISOString(),
300
+ command: options.command,
301
+ selection: {
302
+ ...(options.task_id ? { task_id: options.task_id } : {}),
303
+ ...(options.task_guid ? {
304
+ task_guid: options.task_guid,
305
+ task_id_prefix: taskIdPrefixForGuid(options.task_guid),
306
+ } : {}),
307
+ ...(options.latest ? { latest: true } : {}),
308
+ ...(options.since ? { since: options.since } : {}),
309
+ ...(options.run_dir ? { run_dir: options.run_dir } : {}),
310
+ },
311
+ task_ids: taskIds,
312
+ match_values: matchValues,
313
+ runs: runDirs.map((runDir) => path.basename(runDir)),
314
+ redaction: {
315
+ enabled: true,
316
+ include_content: Boolean(options.include_content),
317
+ },
318
+ local_only: true,
319
+ }
320
+ writeFileSync(file, `${redactText(JSON.stringify(manifest, null, 2))}\n`)
321
+ }
322
+
323
+ function copyFilteredRun(runDir, targetDir, matchValues, includeWholeRun) {
324
+ mkdirSync(targetDir, { recursive: true })
325
+ for (const file of textFiles(runDir)) {
326
+ const relativeName = path.basename(file)
327
+ const target = path.join(targetDir, relativeName)
328
+ if (relativeName === 'manifest.json') {
329
+ writeFileSync(target, redactText(readFileSync(file, 'utf8')))
330
+ continue
331
+ }
332
+
333
+ if (includeWholeRun) {
334
+ writeFileSync(target, redactText(readFileSync(file, 'utf8')))
335
+ continue
336
+ }
337
+
338
+ const matching = readLines(file).filter((line) => lineHasAny(line, matchValues))
339
+ if (matching.length > 0) {
340
+ writeFileSync(target, `${matching.map(redactText).join('\n')}\n`)
341
+ }
342
+ }
343
+ }
344
+
345
+ function createArchive(options, runDirs, taskIds, matchValues) {
346
+ if (runDirs.length === 0) {
347
+ throw new Error('No matching log runs found')
348
+ }
349
+ const stageRoot = mkdtempSync(path.join(os.tmpdir(), 'aamp-logs-bundle-'))
350
+ const bundleName = `aamp-logs-${archiveStamp()}`
351
+ const bundleDir = path.join(stageRoot, bundleName)
352
+ mkdirSync(path.join(bundleDir, 'runs'), { recursive: true })
353
+
354
+ const includeWholeRun = Boolean(options.include_content) || (taskIds.length === 0 && !options.task_guid)
355
+
356
+ for (const runDir of runDirs) {
357
+ copyFilteredRun(runDir, path.join(bundleDir, 'runs', path.basename(runDir)), matchValues, includeWholeRun)
358
+ }
359
+ writeReadme(path.join(bundleDir, 'README.txt'), options, runDirs, taskIds)
360
+ writeBundleManifest(path.join(bundleDir, 'manifest.json'), options, runDirs, taskIds, matchValues)
361
+
362
+ const archiveDir = ensureArchiveDir()
363
+ const archivePath = path.join(archiveDir, `${bundleName}.tar.gz`)
364
+ execFileSync('tar', ['-czf', archivePath, '-C', stageRoot, bundleName])
365
+ rmSync(stageRoot, { recursive: true, force: true })
366
+ return archivePath
367
+ }
368
+
369
+ function collect(options) {
370
+ const logRoot = resolveLogRoot(options)
371
+ let taskIds = []
372
+ if (options.task_id) taskIds = [options.task_id]
373
+ let matchValues = [...taskIds]
374
+ if (options.task_guid) {
375
+ taskIds = collectTaskIdsForGuid(listRunDirs(logRoot), options.task_guid)
376
+ matchValues = [
377
+ taskIdPrefixForGuid(options.task_guid),
378
+ options.task_guid,
379
+ ...taskIds,
380
+ ]
381
+ }
382
+
383
+ if (!options.task_id && !options.task_guid && !options.run_dir && !options.latest && !options.since) {
384
+ throw new Error('collect requires --task-id, --task-guid, --run-dir, --latest, or --since')
385
+ }
386
+
387
+ const runDirs = selectRuns(logRoot, options, matchValues)
388
+ const archivePath = createArchive(options, runDirs, taskIds, matchValues)
389
+ process.stdout.write(`Created local logs bundle:\n${archivePath}\n`)
390
+ }
391
+
392
+ function listRuns(options) {
393
+ const logRoot = resolveLogRoot(options)
394
+ const runs = listRunDirs(logRoot)
395
+ process.stdout.write(`${runs.map((runDir) => path.basename(runDir)).join('\n')}${runs.length ? '\n' : ''}`)
396
+ }
397
+
398
+ function tailMatchValues(logRoot, options) {
399
+ if (options.task_id && options.task_guid) {
400
+ throw new Error('tail accepts only one of --task-id or --task-guid')
401
+ }
402
+ if (options.task_id) return [options.task_id]
403
+ if (options.task_guid) {
404
+ return [
405
+ taskIdPrefixForGuid(options.task_guid),
406
+ options.task_guid,
407
+ ...collectTaskIdsForGuid(listRunDirs(logRoot), options.task_guid),
408
+ ]
409
+ }
410
+ return []
411
+ }
412
+
413
+ function formatTailLine(runDir, file, line) {
414
+ return `${path.basename(runDir)}/${path.basename(file)}: ${redactText(line)}`
415
+ }
416
+
417
+ function writeMatchingTailLines(runDirs, matchValues) {
418
+ const lines = []
419
+ for (const runDir of runDirs) {
420
+ for (const file of textFiles(runDir)) {
421
+ for (const line of readLines(file)) {
422
+ if (matchValues.length === 0 || lineHasAny(line, matchValues)) {
423
+ lines.push(formatTailLine(runDir, file, line))
424
+ }
425
+ }
426
+ }
427
+ }
428
+ process.stdout.write(`${lines.join('\n')}${lines.length ? '\n' : ''}`)
429
+ }
430
+
431
+ function selectFollowRunDirs(logRoot, matchValues) {
432
+ if (matchValues.length > 0) {
433
+ const matching = selectRuns(logRoot, {}, matchValues)
434
+ if (matching.length > 0) return matching
435
+ }
436
+ const run = latestRun(logRoot)
437
+ return run ? [run] : []
438
+ }
439
+
440
+ function followTailFiles(runDirs, matchValues) {
441
+ const files = runDirs.flatMap((runDir) => textFiles(runDir).map((file) => ({ runDir, file })))
442
+ if (files.length === 0) throw new Error('No log files found to follow')
443
+
444
+ const children = []
445
+ let liveChildren = files.length
446
+ const stop = (code = 0) => {
447
+ for (const child of children) {
448
+ if (!child.killed) child.kill()
449
+ }
450
+ process.exit(code)
451
+ }
452
+ process.on('SIGINT', () => stop(0))
453
+ process.on('SIGTERM', () => stop(0))
454
+
455
+ for (const { runDir, file } of files) {
456
+ const child = spawn('tail', ['-n', '0', '-f', file], { stdio: ['ignore', 'pipe', 'pipe'] })
457
+ children.push(child)
458
+
459
+ let buffer = ''
460
+ child.stdout.setEncoding('utf8')
461
+ child.stdout.on('data', (chunk) => {
462
+ buffer += chunk
463
+ const lines = buffer.split(/\r?\n/)
464
+ buffer = lines.pop() ?? ''
465
+ for (const line of lines) {
466
+ if (line.length === 0) continue
467
+ if (matchValues.length > 0 && !lineHasAny(line, matchValues)) continue
468
+ process.stdout.write(`${formatTailLine(runDir, file, line)}\n`)
469
+ }
470
+ })
471
+ child.stderr.setEncoding('utf8')
472
+ child.stderr.on('data', (chunk) => {
473
+ process.stderr.write(chunk)
474
+ })
475
+ child.on('exit', () => {
476
+ liveChildren -= 1
477
+ if (liveChildren === 0) process.exit(0)
478
+ })
479
+ }
480
+ }
481
+
482
+ function tail(options) {
483
+ const logRoot = resolveLogRoot(options)
484
+ const matchValues = tailMatchValues(logRoot, options)
485
+ if (options.follow) {
486
+ const runDirs = selectFollowRunDirs(logRoot, matchValues)
487
+ if (runDirs.length === 0) throw new Error('No log runs found to follow')
488
+ if (matchValues.length > 0) writeMatchingTailLines(runDirs, matchValues)
489
+ followTailFiles(runDirs, matchValues)
490
+ return
491
+ }
492
+ if (matchValues.length === 0) throw new Error('tail requires --task-id, --task-guid, or -f')
493
+ writeMatchingTailLines(listRunDirs(logRoot), matchValues)
494
+ }
495
+
496
+ function main() {
497
+ const args = process.argv.slice(2)
498
+ if (args.length === 1 && (args[0] === '--version' || args[0] === '-v')) {
499
+ process.stdout.write(`${packageVersion()}\n`)
500
+ return
501
+ }
502
+ const options = parseArgs(args)
503
+ if (options.command === 'collect') {
504
+ collect(options)
505
+ return
506
+ }
507
+ if (options.command === 'list-runs') {
508
+ listRuns(options)
509
+ return
510
+ }
511
+ if (options.command === 'tail') {
512
+ tail(options)
513
+ return
514
+ }
515
+ throw new Error(`Unknown command: ${options.command}`)
516
+ }
517
+
518
+ try {
519
+ main()
520
+ } catch (error) {
521
+ process.stderr.write(`aamp-logs: ${(error instanceof Error ? error.message : String(error))}\n`)
522
+ process.exit(1)
523
+ }