@trillboards/cli 0.1.0 → 0.1.3

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.
Files changed (59) hide show
  1. package/README.md +415 -43
  2. package/dist/ads-client.d.ts +146 -0
  3. package/dist/ads-client.js +310 -0
  4. package/dist/agent-hook.d.ts +64 -0
  5. package/dist/agent-hook.js +96 -0
  6. package/dist/capability.d.ts +31 -0
  7. package/dist/capability.js +67 -0
  8. package/dist/claude-settings.d.ts +64 -0
  9. package/dist/claude-settings.js +126 -0
  10. package/dist/commands/ads/disable.d.ts +10 -0
  11. package/dist/commands/ads/disable.js +25 -0
  12. package/dist/commands/ads/enable.d.ts +11 -0
  13. package/dist/commands/ads/enable.js +32 -0
  14. package/dist/commands/ads/hook.d.ts +16 -0
  15. package/dist/commands/ads/hook.js +90 -0
  16. package/dist/commands/ads/login.d.ts +21 -0
  17. package/dist/commands/ads/login.js +108 -0
  18. package/dist/commands/ads/start.d.ts +20 -0
  19. package/dist/commands/ads/start.js +175 -0
  20. package/dist/commands/code.d.ts +12 -0
  21. package/dist/commands/code.js +84 -0
  22. package/dist/commands/devices/reconcile.d.ts +10 -0
  23. package/dist/commands/devices/reconcile.js +94 -0
  24. package/dist/commands/generated/generated-csi-venue-history.d.ts +15 -0
  25. package/dist/commands/generated/generated-csi-venue-history.js +35 -0
  26. package/dist/commands/generated/generated-csi-venue-occupancy.d.ts +14 -0
  27. package/dist/commands/generated/generated-csi-venue-occupancy.js +32 -0
  28. package/dist/commands/generated/generated-federated-get-latest-model-descriptor.d.ts +19 -0
  29. package/dist/commands/generated/generated-federated-get-latest-model-descriptor.js +47 -0
  30. package/dist/commands/generated/generated-federated-get-model-weights.d.ts +19 -0
  31. package/dist/commands/generated/generated-federated-get-model-weights.js +47 -0
  32. package/dist/commands/generated/generated-federated-gradient-upload.d.ts +13 -0
  33. package/dist/commands/generated/generated-federated-gradient-upload.js +32 -0
  34. package/dist/commands/generated/generated-federated-performance-upload.d.ts +13 -0
  35. package/dist/commands/generated/generated-federated-performance-upload.js +32 -0
  36. package/dist/commands/generated/generated-federated-taste-upload.d.ts +13 -0
  37. package/dist/commands/generated/generated-federated-taste-upload.js +32 -0
  38. package/dist/commands/listen.js +9 -1
  39. package/dist/commands/logs/tail.js +10 -1
  40. package/dist/config.d.ts +21 -0
  41. package/dist/config.js +34 -9
  42. package/dist/dead-time.d.ts +51 -0
  43. package/dist/dead-time.js +119 -0
  44. package/dist/format-helpers.d.ts +37 -0
  45. package/dist/format-helpers.js +41 -0
  46. package/dist/http.d.ts +8 -1
  47. package/dist/http.js +6 -1
  48. package/dist/machine-id.d.ts +12 -0
  49. package/dist/machine-id.js +22 -0
  50. package/dist/pty-wrapper.d.ts +88 -0
  51. package/dist/pty-wrapper.js +287 -0
  52. package/dist/render-runner.d.ts +48 -0
  53. package/dist/render-runner.js +151 -0
  54. package/dist/render.d.ts +82 -0
  55. package/dist/render.js +124 -0
  56. package/dist/user-auth.d.ts +57 -0
  57. package/dist/user-auth.js +110 -0
  58. package/oclif.manifest.json +1186 -301
  59. package/package.json +9 -2
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Terminal surface-capability detection for the `trillboards code` wrapper.
3
+ *
4
+ * The render surface available during the agent's dead-time depends on the host
5
+ * terminal. We map the environment to four capabilities and hand them to the
6
+ * server as `?surface_capabilities=…` so it returns the richest creative the
7
+ * surface can actually show (the server's `selectCreativeFormat`).
8
+ *
9
+ * text — every TTY (an OSC-8 sponsor line)
10
+ * image — inline graphics (Kitty / iTerm2 / Sixel; incl. VS Code's terminal for STATIC images)
11
+ * video — real in-terminal video via `mpv --vo=kitty` (Kitty/Ghostty/iTerm2/WezTerm) — NOT VS Code/Cursor
12
+ * webview — we can open an external browser/PiP for an IMA/AdX video (the Leg-A fallback)
13
+ *
14
+ * Pure + env-injectable so it is fully unit-tested.
15
+ */
16
+ /** Terminals where `mpv --vo=kitty` (or iTerm2 inline) plays real video well. */
17
+ const VIDEO_TERM_PROGRAMS = new Set(['iterm.app', 'wezterm', 'ghostty', 'rio', 'konsole']);
18
+ /** Terminals that show inline images but NOT smooth video (static only). */
19
+ const IMAGE_ONLY_TERM_PROGRAMS = new Set(['vscode', 'cursor']);
20
+ function lower(v) {
21
+ return (v ?? '').trim().toLowerCase();
22
+ }
23
+ /**
24
+ * Detect what the host terminal can render, from the environment alone (no
25
+ * round-trip probe — that lives in the wrapper which can do the Kitty `a=q` query
26
+ * for a runtime confirm). `env` is injected so tests cover the matrix.
27
+ */
28
+ export function detectSurfaceCapabilities(env = process.env) {
29
+ const termProgram = lower(env.TERM_PROGRAM);
30
+ const term = lower(env.TERM);
31
+ // Kitty advertises itself via TERM=xterm-kitty (not TERM_PROGRAM); Ghostty/cmux
32
+ // set TERM_PROGRAM=ghostty. iTerm2/WezTerm set TERM_PROGRAM.
33
+ const isKitty = term.includes('kitty') || Boolean(env.KITTY_WINDOW_ID);
34
+ const isVideoTerm = isKitty || VIDEO_TERM_PROGRAMS.has(termProgram);
35
+ const isImageOnlyTerm = IMAGE_ONLY_TERM_PROGRAMS.has(termProgram);
36
+ // Sixel-capable hosts (xterm/mlterm/foot/Windows Terminal) → static images.
37
+ const isSixel = term.includes('sixel') || term.includes('foot') || term.includes('mlterm');
38
+ const imageCapableTerm = isVideoTerm || isImageOnlyTerm || isSixel;
39
+ // The wrapper's in-terminal render surface is a tmux split pane. Without tmux it
40
+ // can only spawn an mpv window (video) or open a browser (webview) — there is no
41
+ // clean way to draw text/an image beside the agent's TUI. So we only advertise
42
+ // text/image when we actually have a renderer (a tmux split); outside tmux the
43
+ // shipped `ads hook` covers in-place text.
44
+ const inTmux = Boolean(env.TMUX);
45
+ return {
46
+ text: inTmux,
47
+ image: imageCapableTerm && inTmux,
48
+ // video renders either in a tmux split (in-terminal) or a spawned mpv window.
49
+ video: isVideoTerm,
50
+ // A browser is essentially always reachable (open/xdg-open/start) — this is the
51
+ // surface AdX video uses where the terminal can't render it natively.
52
+ webview: true,
53
+ };
54
+ }
55
+ /** Serialize for the `?surface_capabilities=` query param the server's selector reads. */
56
+ export function capabilitiesToParam(caps) {
57
+ const out = [];
58
+ if (caps.text)
59
+ out.push('text');
60
+ if (caps.image)
61
+ out.push('image');
62
+ if (caps.video)
63
+ out.push('video');
64
+ if (caps.webview)
65
+ out.push('webview');
66
+ return out.join(',');
67
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Claude Code settings.json hook merge — install/remove the Trillboards dead-time
3
+ * hook without clobbering the developer's own hooks.
4
+ *
5
+ * Claude Code reads `$CLAUDE_CONFIG_DIR/settings.json` (default `~/.claude`) and
6
+ * hot-reloads it (no restart). A hook lives under
7
+ * settings.hooks.<EventName> = [ { matcher?, hooks: [ {type:"command", command} ] } ]
8
+ * We install ONE entry on `UserPromptSubmit` (fires when the dev submits a prompt,
9
+ * i.e. the agent's thinking/dead-time begins) whose command is `trillboards ads hook`.
10
+ * Detection is by the command substring marker so add/remove stay idempotent and
11
+ * never touch the developer's other entries.
12
+ */
13
+ /** Lifecycle event we attach to — prompt submit == agent dead-time begins. */
14
+ export declare const CLAUDE_HOOK_EVENT = "UserPromptSubmit";
15
+ /** The command Claude Code invokes for each dead-time tick. */
16
+ export declare const HOOK_COMMAND = "trillboards ads hook";
17
+ /** Substring that uniquely identifies OUR hook entries (for idempotent add/remove). */
18
+ export declare const HOOK_COMMAND_MARKER = "ads hook";
19
+ /** Per-tick timeout (s). The hook fails open well under this; on timeout the prompt proceeds. */
20
+ export declare const HOOK_TIMEOUT_SECONDS = 5;
21
+ export interface ClaudeHookCommand {
22
+ type: 'command';
23
+ command: string;
24
+ timeout?: number;
25
+ [k: string]: unknown;
26
+ }
27
+ export interface ClaudeHookMatcher {
28
+ matcher?: string;
29
+ hooks: ClaudeHookCommand[];
30
+ [k: string]: unknown;
31
+ }
32
+ export interface ClaudeSettings {
33
+ hooks?: Record<string, ClaudeHookMatcher[]>;
34
+ [k: string]: unknown;
35
+ }
36
+ /** Directory Claude Code reads settings from (CLAUDE_CONFIG_DIR override, else ~/.claude). */
37
+ export declare function claudeSettingsDir(): string;
38
+ export declare function claudeSettingsPath(): string;
39
+ export declare function readClaudeSettings(): ClaudeSettings;
40
+ export interface ClaudeSettingsLoad {
41
+ settings: ClaudeSettings;
42
+ /** True when the file EXISTS but is not valid JSON — callers must NOT overwrite it. */
43
+ malformed: boolean;
44
+ }
45
+ /**
46
+ * Load settings for an EDIT (enable/disable). Unlike readClaudeSettings (best-effort,
47
+ * returns {} for both missing and malformed), this distinguishes a missing file
48
+ * (safe to create) from a malformed one (must NOT be clobbered — overwriting it would
49
+ * delete the developer's real settings).
50
+ */
51
+ export declare function loadClaudeSettingsForEdit(): ClaudeSettingsLoad;
52
+ export declare function writeClaudeSettings(settings: ClaudeSettings): void;
53
+ export declare function hasTrillboardsHook(settings: ClaudeSettings): boolean;
54
+ /**
55
+ * Return NEW settings with our `UserPromptSubmit` hook present exactly once.
56
+ * Preserves every other event AND any developer-owned UserPromptSubmit matchers.
57
+ * Does not mutate the input.
58
+ */
59
+ export declare function addTrillboardsHook(settings: ClaudeSettings, command?: string): ClaudeSettings;
60
+ /**
61
+ * Return NEW settings with our hook removed from every event, pruning any matcher
62
+ * or event that becomes empty. Leaves developer-owned entries untouched.
63
+ */
64
+ export declare function removeTrillboardsHook(settings: ClaudeSettings): ClaudeSettings;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Claude Code settings.json hook merge — install/remove the Trillboards dead-time
3
+ * hook without clobbering the developer's own hooks.
4
+ *
5
+ * Claude Code reads `$CLAUDE_CONFIG_DIR/settings.json` (default `~/.claude`) and
6
+ * hot-reloads it (no restart). A hook lives under
7
+ * settings.hooks.<EventName> = [ { matcher?, hooks: [ {type:"command", command} ] } ]
8
+ * We install ONE entry on `UserPromptSubmit` (fires when the dev submits a prompt,
9
+ * i.e. the agent's thinking/dead-time begins) whose command is `trillboards ads hook`.
10
+ * Detection is by the command substring marker so add/remove stay idempotent and
11
+ * never touch the developer's other entries.
12
+ */
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
14
+ import { homedir } from 'node:os';
15
+ import { dirname, join } from 'node:path';
16
+ /** Lifecycle event we attach to — prompt submit == agent dead-time begins. */
17
+ export const CLAUDE_HOOK_EVENT = 'UserPromptSubmit';
18
+ /** The command Claude Code invokes for each dead-time tick. */
19
+ export const HOOK_COMMAND = 'trillboards ads hook';
20
+ /** Substring that uniquely identifies OUR hook entries (for idempotent add/remove). */
21
+ export const HOOK_COMMAND_MARKER = 'ads hook';
22
+ /** Per-tick timeout (s). The hook fails open well under this; on timeout the prompt proceeds. */
23
+ export const HOOK_TIMEOUT_SECONDS = 5;
24
+ function clone(value) {
25
+ return JSON.parse(JSON.stringify(value));
26
+ }
27
+ /** Directory Claude Code reads settings from (CLAUDE_CONFIG_DIR override, else ~/.claude). */
28
+ export function claudeSettingsDir() {
29
+ const override = process.env.CLAUDE_CONFIG_DIR;
30
+ if (override && override.trim())
31
+ return override;
32
+ return join(homedir(), '.claude');
33
+ }
34
+ export function claudeSettingsPath() {
35
+ return join(claudeSettingsDir(), 'settings.json');
36
+ }
37
+ export function readClaudeSettings() {
38
+ try {
39
+ const file = claudeSettingsPath();
40
+ if (!existsSync(file))
41
+ return {};
42
+ const parsed = JSON.parse(readFileSync(file, 'utf-8'));
43
+ return parsed && typeof parsed === 'object' ? parsed : {};
44
+ }
45
+ catch {
46
+ // A malformed settings.json is the dev's file — never overwrite blindly.
47
+ return {};
48
+ }
49
+ }
50
+ /**
51
+ * Load settings for an EDIT (enable/disable). Unlike readClaudeSettings (best-effort,
52
+ * returns {} for both missing and malformed), this distinguishes a missing file
53
+ * (safe to create) from a malformed one (must NOT be clobbered — overwriting it would
54
+ * delete the developer's real settings).
55
+ */
56
+ export function loadClaudeSettingsForEdit() {
57
+ const file = claudeSettingsPath();
58
+ if (!existsSync(file))
59
+ return { settings: {}, malformed: false };
60
+ try {
61
+ const parsed = JSON.parse(readFileSync(file, 'utf-8'));
62
+ return { settings: parsed && typeof parsed === 'object' ? parsed : {}, malformed: false };
63
+ }
64
+ catch {
65
+ return { settings: {}, malformed: true };
66
+ }
67
+ }
68
+ export function writeClaudeSettings(settings) {
69
+ const file = claudeSettingsPath();
70
+ const dir = dirname(file);
71
+ if (!existsSync(dir))
72
+ mkdirSync(dir, { recursive: true });
73
+ writeFileSync(file, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
74
+ }
75
+ function isOurCommand(h) {
76
+ return typeof h.command === 'string' && h.command.includes(HOOK_COMMAND_MARKER);
77
+ }
78
+ function isOurMatcher(m) {
79
+ return Array.isArray(m.hooks) && m.hooks.some(isOurCommand);
80
+ }
81
+ export function hasTrillboardsHook(settings) {
82
+ const events = settings.hooks;
83
+ if (!events)
84
+ return false;
85
+ return Object.values(events).some((list) => Array.isArray(list) && list.some(isOurMatcher));
86
+ }
87
+ /**
88
+ * Return NEW settings with our `UserPromptSubmit` hook present exactly once.
89
+ * Preserves every other event AND any developer-owned UserPromptSubmit matchers.
90
+ * Does not mutate the input.
91
+ */
92
+ export function addTrillboardsHook(settings, command = HOOK_COMMAND) {
93
+ const next = clone(settings);
94
+ if (!next.hooks || typeof next.hooks !== 'object')
95
+ next.hooks = {};
96
+ const list = Array.isArray(next.hooks[CLAUDE_HOOK_EVENT]) ? next.hooks[CLAUDE_HOOK_EVENT] : [];
97
+ next.hooks[CLAUDE_HOOK_EVENT] = list;
98
+ if (list.some(isOurMatcher))
99
+ return next; // already installed — idempotent
100
+ list.push({ hooks: [{ type: 'command', command, timeout: HOOK_TIMEOUT_SECONDS }] });
101
+ return next;
102
+ }
103
+ /**
104
+ * Return NEW settings with our hook removed from every event, pruning any matcher
105
+ * or event that becomes empty. Leaves developer-owned entries untouched.
106
+ */
107
+ export function removeTrillboardsHook(settings) {
108
+ const next = clone(settings);
109
+ if (!next.hooks || typeof next.hooks !== 'object')
110
+ return next;
111
+ for (const event of Object.keys(next.hooks)) {
112
+ const list = next.hooks[event];
113
+ if (!Array.isArray(list))
114
+ continue;
115
+ const pruned = list
116
+ .map((m) => (Array.isArray(m.hooks) ? { ...m, hooks: m.hooks.filter((h) => !isOurCommand(h)) } : m))
117
+ .filter((m) => !Array.isArray(m.hooks) || m.hooks.length > 0);
118
+ if (pruned.length > 0)
119
+ next.hooks[event] = pruned;
120
+ else
121
+ delete next.hooks[event];
122
+ }
123
+ if (Object.keys(next.hooks).length === 0)
124
+ delete next.hooks;
125
+ return next;
126
+ }
@@ -0,0 +1,10 @@
1
+ import { BaseCommand } from '../../base-command.js';
2
+ /**
3
+ * Opt-out: remove the Claude Code dead-time hook. Leaves the developer's other
4
+ * hooks untouched. Idempotent.
5
+ */
6
+ export default class AdsDisable extends BaseCommand {
7
+ static description: string;
8
+ static examples: string[];
9
+ run(): Promise<void>;
10
+ }
@@ -0,0 +1,25 @@
1
+ import { BaseCommand } from '../../base-command.js';
2
+ import { claudeSettingsPath, hasTrillboardsHook, loadClaudeSettingsForEdit, removeTrillboardsHook, writeClaudeSettings, } from '../../claude-settings.js';
3
+ import { colorize } from '../../format-helpers.js';
4
+ /**
5
+ * Opt-out: remove the Claude Code dead-time hook. Leaves the developer's other
6
+ * hooks untouched. Idempotent.
7
+ */
8
+ export default class AdsDisable extends BaseCommand {
9
+ static description = 'Turn off the in-place sponsored lines (removes the Claude Code dead-time hook).';
10
+ static examples = ['<%= config.bin %> ads disable'];
11
+ async run() {
12
+ const { flags } = await this.parse(AdsDisable);
13
+ this.setParsedFlags(flags);
14
+ const { settings, malformed } = loadClaudeSettingsForEdit();
15
+ if (malformed) {
16
+ this.error(`${claudeSettingsPath()} is not valid JSON — fix it first. Refusing to modify your Claude Code settings.`, { exit: 1 });
17
+ }
18
+ if (!hasTrillboardsHook(settings)) {
19
+ this.log(`Not enabled — no Trillboards hook found in ${claudeSettingsPath()}.`);
20
+ return;
21
+ }
22
+ writeClaudeSettings(removeTrillboardsHook(settings));
23
+ this.log(`${colorize('In-place agent ads disabled.', 'green')} The Claude Code dead-time hook was removed.`);
24
+ }
25
+ }
@@ -0,0 +1,11 @@
1
+ import { BaseCommand } from '../../base-command.js';
2
+ /**
3
+ * Opt-in: install the Claude Code dead-time hook so a single sponsored line shows
4
+ * in-place while the agent thinks — no browser, no video player. Idempotent;
5
+ * preserves the developer's other hooks. Reverse with `ads disable`.
6
+ */
7
+ export default class AdsEnable extends BaseCommand {
8
+ static description: string;
9
+ static examples: string[];
10
+ run(): Promise<void>;
11
+ }
@@ -0,0 +1,32 @@
1
+ import { BaseCommand } from '../../base-command.js';
2
+ import { HOOK_COMMAND, addTrillboardsHook, claudeSettingsPath, hasTrillboardsHook, loadClaudeSettingsForEdit, writeClaudeSettings, } from '../../claude-settings.js';
3
+ import { colorize } from '../../format-helpers.js';
4
+ /**
5
+ * Opt-in: install the Claude Code dead-time hook so a single sponsored line shows
6
+ * in-place while the agent thinks — no browser, no video player. Idempotent;
7
+ * preserves the developer's other hooks. Reverse with `ads disable`.
8
+ */
9
+ export default class AdsEnable extends BaseCommand {
10
+ static description = 'Turn on in-place sponsored lines in Claude Code (installs the dead-time hook). Opt-in; undo with `ads disable`.';
11
+ static examples = ['<%= config.bin %> ads enable'];
12
+ async run() {
13
+ const { flags } = await this.parse(AdsEnable);
14
+ this.setParsedFlags(flags);
15
+ const { settings, malformed } = loadClaudeSettingsForEdit();
16
+ if (malformed) {
17
+ this.error(`${claudeSettingsPath()} is not valid JSON — fix it first. Refusing to overwrite your Claude Code settings.`, { exit: 1 });
18
+ }
19
+ if (hasTrillboardsHook(settings)) {
20
+ this.log(`${colorize('Already enabled', 'green')} — the Trillboards dead-time hook is installed in ${claudeSettingsPath()}.`);
21
+ return;
22
+ }
23
+ writeClaudeSettings(addTrillboardsHook(settings, HOOK_COMMAND));
24
+ this.log(colorize('In-place agent ads enabled.', 'green'));
25
+ this.log('');
26
+ this.log(`Added a Claude Code UserPromptSubmit hook to ${claudeSettingsPath()} (hot-reloads, no restart).`);
27
+ this.log('While your agent is thinking, a single sponsored line now appears in-place — no browser, no player.');
28
+ this.log('');
29
+ this.log(colorize('First register this machine to earn: trillboards ads start --once', 'dim'));
30
+ this.log(colorize('Turn it off anytime: trillboards ads disable', 'dim'));
31
+ }
32
+ }
@@ -0,0 +1,16 @@
1
+ import { Command } from '@oclif/core';
2
+ /**
3
+ * Internal handler invoked by Claude Code's `UserPromptSubmit` hook (NOT by humans
4
+ * — hidden from help). On each prompt submit (the agent's dead-time begins) it may
5
+ * print `{"systemMessage": "<sponsor line>"}` to stdout, which Claude Code renders
6
+ * to the developer in-place. Prints nothing when there's no ad to show.
7
+ *
8
+ * Contract: this must NEVER block or break the prompt. It always exits 0, never
9
+ * throws, and only emits JSON when there is genuinely something to show.
10
+ */
11
+ export default class AdsHook extends Command {
12
+ static description: string;
13
+ static hidden: boolean;
14
+ static strict: boolean;
15
+ run(): Promise<void>;
16
+ }
@@ -0,0 +1,90 @@
1
+ import { Command } from '@oclif/core';
2
+ import { fetchVirtualScreenTextAd, reportTextAdEvent } from '../../ads-client.js';
3
+ import { agentAdIntervalMs, runAgentHook } from '../../agent-hook.js';
4
+ import { readConfig, updateConfig } from '../../config.js';
5
+ /** Tight bound on the post-render telemetry so it never delays the hook near Claude's timeout. */
6
+ const TELEMETRY_TIMEOUT_MS = 1200;
7
+ /**
8
+ * Internal handler invoked by Claude Code's `UserPromptSubmit` hook (NOT by humans
9
+ * — hidden from help). On each prompt submit (the agent's dead-time begins) it may
10
+ * print `{"systemMessage": "<sponsor line>"}` to stdout, which Claude Code renders
11
+ * to the developer in-place. Prints nothing when there's no ad to show.
12
+ *
13
+ * Contract: this must NEVER block or break the prompt. It always exits 0, never
14
+ * throws, and only emits JSON when there is genuinely something to show.
15
+ */
16
+ export default class AdsHook extends Command {
17
+ static description = 'Internal: Claude Code dead-time hook handler (renders an in-place sponsor line).';
18
+ static hidden = true;
19
+ // Tolerate any args/flags Claude Code might pass; the event payload is on stdin.
20
+ static strict = false;
21
+ async run() {
22
+ // Drain stdin (Claude Code pipes the hook event JSON). We don't need its
23
+ // contents, but reading prevents a broken pipe on the caller's side.
24
+ await drainStdin(500);
25
+ let result = {};
26
+ let baseUrl = 'https://api.trillboards.com';
27
+ try {
28
+ const cfg = readConfig();
29
+ baseUrl = process.env.TRILLBOARDS_BASE_URL || cfg.base_url || 'https://api.trillboards.com';
30
+ result = await runAgentHook({
31
+ now: Date.now(),
32
+ screenId: cfg.screen_id ?? null,
33
+ deviceId: cfg.device_id ?? null,
34
+ baseUrl,
35
+ lastShownAt: cfg.agent_hook_last_shown_at,
36
+ intervalMs: agentAdIntervalMs(),
37
+ fetchTextAd: (b, s) => fetchVirtualScreenTextAd(b, s),
38
+ markShown: (now) => updateConfig({ agent_hook_last_shown_at: now }),
39
+ });
40
+ }
41
+ catch {
42
+ result = {}; // fail open
43
+ }
44
+ // Render FIRST: emit the sponsor line before any telemetry so a slow event
45
+ // endpoint can never hold it past Claude Code's hook timeout. Empty stdout =
46
+ // no-op for the hook.
47
+ if (result.systemMessage) {
48
+ process.stdout.write(JSON.stringify({ systemMessage: result.systemMessage }));
49
+ }
50
+ // Best-effort, tightly-bounded, non-billable telemetry. Never throws/blocks.
51
+ if (result.report) {
52
+ try {
53
+ await reportTextAdEvent(baseUrl, result.report, TELEMETRY_TIMEOUT_MS);
54
+ }
55
+ catch {
56
+ // ignore — telemetry must never affect the prompt
57
+ }
58
+ }
59
+ }
60
+ }
61
+ /** Read+discard stdin, resolving on end/error or after a short fallback timeout. */
62
+ function drainStdin(timeoutMs) {
63
+ return new Promise((resolve) => {
64
+ let done = false;
65
+ const finish = () => {
66
+ if (done)
67
+ return;
68
+ done = true;
69
+ resolve();
70
+ };
71
+ try {
72
+ const stdin = process.stdin;
73
+ if (stdin.isTTY) {
74
+ finish();
75
+ return;
76
+ }
77
+ stdin.on('data', () => {
78
+ /* discard */
79
+ });
80
+ stdin.on('end', finish);
81
+ stdin.on('error', finish);
82
+ stdin.resume();
83
+ }
84
+ catch {
85
+ finish();
86
+ return;
87
+ }
88
+ setTimeout(finish, timeoutMs);
89
+ });
90
+ }
@@ -0,0 +1,21 @@
1
+ import { BaseCommand } from '../../base-command.js';
2
+ /**
3
+ * `trillboards ads login` — authenticate as the DEVELOPER (Trillboards user) to
4
+ * rent agent dead-time. Stores a user JWT pair (user_token + user_refresh_token),
5
+ * which is distinct from the partner `api_key` used by the other commands.
6
+ */
7
+ export default class AdsLogin extends BaseCommand {
8
+ static description: string;
9
+ static examples: string[];
10
+ static flags: {
11
+ email: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
+ password: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
+ };
14
+ run(): Promise<void>;
15
+ private promptLine;
16
+ /**
17
+ * Prompt for a secret without echoing it. Falls back to a plain prompt if the
18
+ * stream isn't a TTY. The token/password is never logged.
19
+ */
20
+ private promptHidden;
21
+ }
@@ -0,0 +1,108 @@
1
+ import { Flags } from '@oclif/core';
2
+ import * as readline from 'node:readline';
3
+ import { BaseCommand } from '../../base-command.js';
4
+ import { getConfigPath, updateConfig } from '../../config.js';
5
+ import { colorize } from '../../format-helpers.js';
6
+ import { TrillboardsApiError } from '../../http.js';
7
+ import { loginUser, persistTokens } from '../../user-auth.js';
8
+ /**
9
+ * `trillboards ads login` — authenticate as the DEVELOPER (Trillboards user) to
10
+ * rent agent dead-time. Stores a user JWT pair (user_token + user_refresh_token),
11
+ * which is distinct from the partner `api_key` used by the other commands.
12
+ */
13
+ export default class AdsLogin extends BaseCommand {
14
+ static description = 'Log in to the agent-ads program with your Trillboards account (email + password)';
15
+ static examples = [
16
+ '<%= config.bin %> ads login',
17
+ '<%= config.bin %> ads login --email you@example.com',
18
+ 'TRILLBOARDS_USER_TOKEN=<jwt> <%= config.bin %> ads login',
19
+ ];
20
+ static flags = {
21
+ email: Flags.string({
22
+ description: 'Account email (prompts if omitted)',
23
+ }),
24
+ password: Flags.string({
25
+ description: 'Account password (prompts hidden if omitted; avoid passing on the command line)',
26
+ }),
27
+ };
28
+ async run() {
29
+ const { flags } = await this.parse(AdsLogin);
30
+ this.setParsedFlags(flags);
31
+ const baseUrl = this.getBaseUrl();
32
+ // Headless hatch: a pre-minted JWT pasted via env. We can't refresh it (no
33
+ // refresh token), but it lets CI / headless installs authenticate.
34
+ const envToken = process.env.TRILLBOARDS_USER_TOKEN;
35
+ if (envToken && envToken.trim()) {
36
+ updateConfig({ user_token: envToken.trim(), base_url: baseUrl });
37
+ this.log(`Saved user token from TRILLBOARDS_USER_TOKEN. Config: ${getConfigPath()}`);
38
+ this.log(colorize('Note: env tokens are not auto-refreshed. Run `ads login` with email+password for a renewable session.', 'dim'));
39
+ return;
40
+ }
41
+ const email = flags.email || (await this.promptLine('Email: '));
42
+ if (!email) {
43
+ this.error('No email provided. Pass --email or set TRILLBOARDS_USER_TOKEN.', { exit: 1 });
44
+ }
45
+ const password = flags.password || (await this.promptHidden('Password: '));
46
+ if (!password) {
47
+ this.error('No password provided.', { exit: 1 });
48
+ }
49
+ this.log('Signing in...');
50
+ let result;
51
+ try {
52
+ result = await loginUser(baseUrl, email, password);
53
+ }
54
+ catch (error) {
55
+ if (error instanceof TrillboardsApiError) {
56
+ if (error.statusCode === 401)
57
+ this.error('Invalid email or password.', { exit: 1 });
58
+ if (error.statusCode === 404)
59
+ this.error('No account found for that email.', { exit: 1 });
60
+ this.error(`Login failed: ${error.message}`, { exit: 1 });
61
+ }
62
+ throw error;
63
+ }
64
+ persistTokens(result);
65
+ updateConfig({ base_url: baseUrl, user_email: email });
66
+ this.log(colorize(`Logged in as ${email}.`, 'green'));
67
+ this.log(`Session saved to ${getConfigPath()}`);
68
+ this.log(colorize('Next: run `trillboards ads start` to rent your agent dead-time.', 'dim'));
69
+ }
70
+ promptLine(prompt) {
71
+ if (!process.stdin.isTTY)
72
+ return Promise.resolve('');
73
+ return new Promise((resolve) => {
74
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
75
+ rl.question(prompt, (answer) => {
76
+ rl.close();
77
+ resolve(answer.trim());
78
+ });
79
+ });
80
+ }
81
+ /**
82
+ * Prompt for a secret without echoing it. Falls back to a plain prompt if the
83
+ * stream isn't a TTY. The token/password is never logged.
84
+ */
85
+ promptHidden(prompt) {
86
+ if (!process.stdin.isTTY)
87
+ return Promise.resolve('');
88
+ return new Promise((resolve) => {
89
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
90
+ const rlAny = rl;
91
+ // Mask everything after the prompt is written.
92
+ rlAny._writeToOutput = (stringToWrite) => {
93
+ if (stringToWrite.includes(prompt)) {
94
+ rlAny.output?.write(prompt);
95
+ }
96
+ else if (stringToWrite === '\n' || stringToWrite === '\r\n') {
97
+ rlAny.output?.write(stringToWrite);
98
+ }
99
+ // swallow echoed characters
100
+ };
101
+ rl.question(prompt, (answer) => {
102
+ rl.close();
103
+ process.stderr.write('\n');
104
+ resolve(answer.trim());
105
+ });
106
+ });
107
+ }
108
+ }
@@ -0,0 +1,20 @@
1
+ import { BaseCommand } from '../../base-command.js';
2
+ export default class AdsStart extends BaseCommand {
3
+ static description: string;
4
+ static examples: string[];
5
+ static flags: {
6
+ name: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
7
+ once: import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
+ simulate: import("@oclif/core/interfaces").BooleanFlag<boolean>;
9
+ interval: import("@oclif/core/interfaces").OptionFlag<number | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
+ };
11
+ private shuttingDown;
12
+ run(): Promise<void>;
13
+ static sleep(ms: number): Promise<void>;
14
+ private heartbeat;
15
+ /**
16
+ * One dead-time fill: fetch the web VAST tag, print the sanctioned text ad with
17
+ * an OSC-8 link to the billable hosted player, and fire the text-ad impression.
18
+ */
19
+ private serveOneAd;
20
+ }