diff --git a/.github/workflows/capability-contract.yml b/.github/workflows/capability-contract.yml index 52726c5..baded4e 100644 --- a/.github/workflows/capability-contract.yml +++ b/.github/workflows/capability-contract.yml @@ -1,9 +1,7 @@ name: Capability Contract on: - pull_request: - push: - branches: [main] + workflow_dispatch: jobs: parity: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6697751..5f1c5f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,12 +1,7 @@ name: CI on: - push: - branches: - - main - pull_request: - branches: - - main + workflow_dispatch: jobs: checks: diff --git a/.github/workflows/guardrails-audit.yml b/.github/workflows/guardrails-audit.yml index 367d5e8..a940563 100644 --- a/.github/workflows/guardrails-audit.yml +++ b/.github/workflows/guardrails-audit.yml @@ -1,13 +1,7 @@ name: Guardrails Audit on: - push: - branches: [main] - pull_request: - branches: [main] workflow_dispatch: - schedule: - - cron: "15 6 * * *" jobs: audit: diff --git a/.github/workflows/mcp-nightly-regression.yml b/.github/workflows/mcp-nightly-regression.yml index 3fb6b25..6299754 100644 --- a/.github/workflows/mcp-nightly-regression.yml +++ b/.github/workflows/mcp-nightly-regression.yml @@ -1,8 +1,6 @@ name: MCP Nightly Regression on: - schedule: - - cron: "0 6 * * *" workflow_dispatch: jobs: diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 95f520d..e3848c4 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -1,20 +1,11 @@ name: Release Drafter on: - push: - # branches to target - branches: - - main - # pull_request events, but only when the PR is merged - pull_request: - types: - - closed + workflow_dispatch: jobs: update_release_draft: runs-on: ubuntu-latest - # Only run when merged or when pushed to main - if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.merged_at != null) steps: # Drafts your Next Release Notes as Pull Requests are merged into "main" - uses: release-drafter/release-drafter@v6 diff --git a/.github/workflows/release-parity.yml b/.github/workflows/release-parity.yml index b7406fc..85ab052 100644 --- a/.github/workflows/release-parity.yml +++ b/.github/workflows/release-parity.yml @@ -1,8 +1,6 @@ name: Release Parity on: - release: - types: [published] workflow_dispatch: jobs: diff --git a/README.md b/README.md index 0acea76..da8fc5a 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,44 @@ xint tr # trends xint bm # bookmarks ``` +### TUI Customization + +```bash +# Built-in themes: classic | neon | minimal | ocean | amber +XINT_TUI_THEME=ocean xint tui + +# Disable animated hero line +XINT_TUI_HERO=0 xint tui + +# Disable icons in menu rows +XINT_TUI_ICONS=0 xint tui + +# Force ASCII borders +XINT_TUI_ASCII=1 xint tui + +# Optional theme token file +XINT_TUI_THEME_FILE=./tui-theme.tokens.example.json xint tui +``` + +### TUI Customization + +```bash +# Built-in themes: classic | neon | minimal | ocean | amber +XINT_TUI_THEME=ocean xint tui + +# Disable animated hero line +XINT_TUI_HERO=0 xint tui + +# Disable icons in menu rows +XINT_TUI_ICONS=0 xint tui + +# Force ASCII borders +XINT_TUI_ASCII=1 xint tui + +# Optional theme token file +XINT_TUI_THEME_FILE=./tui-theme.tokens.example.json xint tui +``` + ## Setup ### 1. X API Key @@ -349,7 +387,7 @@ Runs an MCP server AI agents can connect to. xint mcp --sse --port=3000 # Optional: require bearer auth (recommended if binding beyond loopback) -XINT_MCP_AUTH_TOKEN=change-me xint mcp --sse --host=127.0.0.1 +XINT_MCP_AUTH_TOKEN=replace-with-long-random-token xint mcp --sse --host=127.0.0.1 ``` Security defaults: diff --git a/SKILL.md b/SKILL.md index 1eda97d..9b91565 100644 --- a/SKILL.md +++ b/SKILL.md @@ -53,7 +53,7 @@ For X API details (endpoints, operators, response format): read `references/x-ap This skill requires sensitive credentials. Follow these guidelines: ### Credentials -- **X_BEARER_TOKEN**: Required for X API. Treat as a secret - only set in environment or `.env` file +- **X_BEARER_TOKEN**: Required for X API. Treat as a secret - prefer exported environment variables (optional project-local `.env`) - **XAI_API_KEY**: Optional, needed for AI analysis. Also a secret - **X_CLIENT_ID**: Optional, needed for OAuth. Less sensitive but don't expose publicly - **XAI_MANAGEMENT_API_KEY**: Optional, for collections management @@ -70,7 +70,7 @@ This skill requires sensitive credentials. Follow these guidelines: - Avoid sending sensitive search queries or token-bearing URLs to third-party destinations ### Runtime Notes -- This document is descriptive; it does not modify runtime/system prompts +- This file documents usage and safety controls for the CLI only. - Network listeners are opt-in (`mcp --sse`) and disabled by default - Webhook delivery is opt-in (`--webhook`) and disabled by default diff --git a/lib/tui.test.ts b/lib/tui.test.ts new file mode 100644 index 0000000..3ed4212 --- /dev/null +++ b/lib/tui.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test"; +import { __tuiTestUtils } from "./tui"; + +describe("tui helpers", () => { + test("sanitizeOutputLine removes ANSI and control sequences", () => { + const line = "\x1b[31merror\x1b[0m\x1b]0;title\x07\r"; + expect(__tuiTestUtils.sanitizeOutputLine(line)).toBe("error"); + }); + + test("applyMenuKeyEvent toggles stderr stream view", () => { + const uiState = { + activeIndex: 0, + tab: "commands" as const, + outputOffset: 2, + outputSearch: "", + showStderr: false, + }; + + const result = __tuiTestUtils.applyMenuKeyEvent("e", {}, uiState); + expect(result.resolve).toBeUndefined(); + expect(uiState.tab).toBe("output"); + expect(uiState.showStderr).toBe(true); + expect(uiState.outputOffset).toBe(0); + }); + + test("outputViewLines reads selected stream", () => { + const session = { + lastCommand: "xint search ai", + lastStatus: "success", + lastStdoutLines: ["[stdout] ok 1", "[stdout] ok 2"], + lastStderrLines: ["[stderr] warning"], + lastOutputLines: ["[stdout] ok 1", "[stdout] ok 2", "[stderr] warning"], + }; + + const uiState = { + activeIndex: 0, + tab: "output" as const, + outputOffset: 0, + outputSearch: "", + showStderr: false, + }; + + const stdoutLines = __tuiTestUtils.outputViewLines(session, uiState, 10).join("\n"); + expect(stdoutLines).toContain("stream: stdout (2)"); + expect(stdoutLines).toContain("[stdout] ok 2"); + expect(stdoutLines).not.toContain("[stderr] warning"); + + uiState.showStderr = true; + const stderrLines = __tuiTestUtils.outputViewLines(session, uiState, 10).join("\n"); + expect(stderrLines).toContain("stream: stderr (1)"); + expect(stderrLines).toContain("[stderr] warning"); + expect(stderrLines).not.toContain("[stdout] ok 2"); + }); +}); diff --git a/lib/tui.ts b/lib/tui.ts index 63fbc80..13491c4 100644 --- a/lib/tui.ts +++ b/lib/tui.ts @@ -1,4 +1,5 @@ import { join } from "path"; +import { readFileSync } from "fs"; import { createInterface } from "readline/promises"; import { emitKeypressEvents } from "readline"; import { stdin as input, stdout as output } from "process"; @@ -18,6 +19,8 @@ type SessionState = { lastArticleUrl?: string; lastCommand?: string; lastStatus?: string; + lastStdoutLines: string[]; + lastStderrLines: string[]; lastOutputLines: string[]; }; @@ -25,6 +28,7 @@ type Theme = { accent: string; border: string; muted: string; + hero: string; reset: string; }; @@ -35,15 +39,37 @@ type UiState = { tab: DashboardTab; outputOffset: number; outputSearch: string; + showStderr: boolean; inlinePromptLabel?: string; inlinePromptValue?: string; }; + type UiPhase = "IDLE" | "INPUT" | "RUNNING" | "DONE" | "ERROR"; +type KeypressLike = { name?: string; ctrl?: boolean }; +type RunEvent = + | { type: "line"; line: string } + | { type: "tick"; spinner: string } + | { type: "exit"; code: number }; +type BorderChars = { + tl: string; + tr: string; + bl: string; + br: string; + h: string; + v: string; + tj: string; + bj: string; + lj: string; + rj: string; + x: string; +}; const THEMES: Record = { - minimal: { accent: "\x1b[1m", border: "", muted: "", reset: "\x1b[0m" }, - classic: { accent: "\x1b[1;36m", border: "\x1b[2m", muted: "\x1b[2m", reset: "\x1b[0m" }, - neon: { accent: "\x1b[1;95m", border: "\x1b[38;5;45m", muted: "\x1b[38;5;244m", reset: "\x1b[0m" }, + minimal: { accent: "\x1b[1m", border: "", muted: "", hero: "\x1b[1m", reset: "\x1b[0m" }, + classic: { accent: "\x1b[1;36m", border: "\x1b[2m", muted: "\x1b[2m", hero: "\x1b[1;34m", reset: "\x1b[0m" }, + neon: { accent: "\x1b[1;95m", border: "\x1b[38;5;45m", muted: "\x1b[38;5;244m", hero: "\x1b[1;92m", reset: "\x1b[0m" }, + ocean: { accent: "\x1b[1;96m", border: "\x1b[38;5;39m", muted: "\x1b[38;5;244m", hero: "\x1b[1;94m", reset: "\x1b[0m" }, + amber: { accent: "\x1b[1;33m", border: "\x1b[38;5;214m", muted: "\x1b[38;5;244m", hero: "\x1b[1;220m", reset: "\x1b[0m" }, }; const HELP_LINES = [ @@ -52,6 +78,7 @@ const HELP_LINES = [ " Enter: Run selected command", " Tab: Switch tabs", " F: Output search (filter)", + " E: Toggle stderr stream", " PgUp/PgDn: Scroll output", " /: Command palette", " ?: Open Help tab", @@ -60,7 +87,37 @@ const HELP_LINES = [ function activeTheme(): Theme { const requested = (process.env.XINT_TUI_THEME || "classic").toLowerCase(); - return THEMES[requested] ?? THEMES.classic; + const base = THEMES[requested] ?? THEMES.classic; + const fromFile = loadThemeFromFile(); + return { ...base, ...fromFile }; +} + +function loadThemeFromFile(): Partial { + const path = process.env.XINT_TUI_THEME_FILE; + if (!path) return {}; + try { + const raw = readFileSync(path, "utf8"); + const parsed = JSON.parse(raw) as Partial>; + const out: Partial = {}; + for (const key of ["accent", "border", "muted", "hero", "reset"] as Array) { + if (typeof parsed[key] === "string") out[key] = parsed[key] as string; + } + return out; + } catch { + return {}; + } +} + +function isHeroEnabled(): boolean { + return process.env.XINT_TUI_HERO !== "0"; +} + +function buildHeroLine(uiState: UiState, session: SessionState, width: number): string { + const phase = resolveUiPhase(session, uiState); + const palette = phase === "RUNNING" ? ["▁", "▂", "▃", "▄", "▅", "▆", "▇"] : ["·", "•", "·", "•", "·"]; + const tick = Math.floor(Date.now() / 110); + const wave = Array.from({ length: 12 }, (_, i) => palette[(tick + i) % palette.length]).join(""); + return padText(` xint intelligence console ${wave}`, width); } function clipText(value: string, width: number): string { @@ -74,6 +131,55 @@ function padText(value: string, width: number): string { return clipText(value, width).padEnd(width, " "); } +function activeBorderChars(): BorderChars { + if (process.env.XINT_TUI_ASCII === "1") { + return { tl: "+", tr: "+", bl: "+", br: "+", h: "-", v: "|", tj: "+", bj: "+", lj: "+", rj: "+", x: "+" }; + } + return { tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│", tj: "┬", bj: "┴", lj: "├", rj: "┤", x: "┼" }; +} + +function iconForAction(key: string): string { + if (process.env.XINT_TUI_ICONS === "0") return ""; + const icons: Record = { + "1": "⌕", + "2": "◍", + "3": "◉", + "4": "↳", + "5": "✦", + "6": "?", + }; + return icons[key] ? `${icons[key]} ` : ""; +} + +function buildTabs(uiState: UiState): string { + return (["commands", "output", "help"] as DashboardTab[]) + .map((tab, index) => { + const label = `${index + 1}:${tabLabel(tab)}`; + return tab === uiState.tab ? `‹${label}›` : `[${label}]`; + }) + .join(" "); +} + +function buildHeaderTracker(uiState: UiState, width: number): string { + const railWidth = Math.max(8, Math.min(18, width)); + const cursorBasis = uiState.inlinePromptLabel + ? (uiState.inlinePromptValue ?? "").length + : uiState.activeIndex * 4 + uiState.outputOffset; + const pos = cursorBasis % railWidth; + const left = "·".repeat(pos); + const right = "·".repeat(Math.max(0, railWidth - pos - 1)); + return `focus ${left}●${right}`; +} + +function sanitizeOutputLine(line: string): string { + const ansiCsi = /\x1b\[[0-9;?]*[ -/]*[@-~]/g; + const ansiOsc = /\x1b\][^\x07]*(\x07|\x1b\\)/g; + return line + .replace(ansiOsc, "") + .replace(ansiCsi, "") + .replace(/[\x00-\x08\x0b-\x1f\x7f]/g, ""); +} + function matchPalette(query: string): InteractiveAction | null { const trimmed = query.trim(); if (!trimmed) return null; @@ -106,7 +212,7 @@ function buildMenuLines(activeIndex: number): string[] { INTERACTIVE_ACTIONS.forEach((option, index) => { const pointer = index === activeIndex ? ">" : " "; const aliases = option.aliases.length > 0 ? ` (${option.aliases.join(", ")})` : ""; - lines.push(`${pointer} ${option.key}) ${option.label}${aliases}`); + lines.push(`${pointer} ${option.key}) ${iconForAction(option.key)}${option.label}${aliases}`); lines.push(` ${option.hint}`); }); return lines; @@ -146,7 +252,8 @@ function phaseBadge(phase: UiPhase): string { } function outputViewLines(session: SessionState, uiState: UiState, viewport: number): string[] { - const source = session.lastOutputLines; + const source = uiState.showStderr ? session.lastStderrLines : session.lastStdoutLines; + const streamName = uiState.showStderr ? "stderr" : "stdout"; const q = uiState.outputSearch.trim().toLowerCase(); const filtered = q.length === 0 ? source : source.filter((line) => line.toLowerCase().includes(q)); @@ -165,6 +272,7 @@ function outputViewLines(session: SessionState, uiState: UiState, viewport: numb `phase: ${phaseBadge(resolveUiPhase(session, uiState))}`, `command: ${session.lastCommand ?? "-"}`, `status: ${session.lastStatus ?? "-"}`, + `stream: ${streamName} (${source.length}) | stdout=${session.lastStdoutLines.length} stderr=${session.lastStderrLines.length}`, `filter: ${uiState.outputSearch || "(none)"}`, "", "output:", @@ -172,7 +280,7 @@ function outputViewLines(session: SessionState, uiState: UiState, viewport: numb if (uiState.inlinePromptLabel) { lines.push(""); - lines.push(`${uiState.inlinePromptLabel}`); + lines.push(uiState.inlinePromptLabel); lines.push(`> ${(uiState.inlinePromptValue ?? "")}█`); lines.push(""); } @@ -210,34 +318,33 @@ function buildStatusLine(session: SessionState, uiState: UiState, width: number) : `tab:${tabLabel(uiState.tab)}`; const status = session.lastStatus ?? "-"; return padText( - ` ${phaseBadge(phase)} ${selected.key}:${selected.label} | ${focus} | ${status} `, + ` ${phaseBadge(phase)} ${selected.key}:${selected.label} | ${focus} | stream:${uiState.showStderr ? "stderr" : "stdout"} | ${status} `, Math.max(1, width), ); } function renderDoublePane(uiState: UiState, session: SessionState, columns: number, rows: number): void { const theme = activeTheme(); + const border = activeBorderChars(); const leftBoxWidth = Math.max(46, Math.floor(columns * 0.45)); const rightBoxWidth = Math.max(30, columns - leftBoxWidth - 1); const leftInner = Math.max(20, leftBoxWidth - 2); const rightInner = Math.max(20, rightBoxWidth - 2); - const totalRows = Math.max(12, rows - 8); + const totalRows = Math.max(12, rows - (isHeroEnabled() ? 10 : 9)); const leftLines = buildMenuLines(uiState.activeIndex); const rightLines = buildTabLines(session, uiState, totalRows).slice(-totalRows); - const tabs = (["commands", "output", "help"] as DashboardTab[]) - .map((tab, index) => { - const label = `${index + 1}:${tabLabel(tab)}`; - return tab === uiState.tab ? `${theme.accent}[ ${label} ]${theme.reset}` : `[ ${label} ]`; - }) - .join(" "); + const tabs = buildTabs(uiState); + const tracker = buildHeaderTracker(uiState, 16); - output.write("\x1b[2J\x1b[H"); - output.write(`${theme.border}+${"-".repeat(Math.max(1, columns - 2))}+${theme.reset}\n`); - output.write( - `${theme.border}|${theme.reset}${padText(` xint dashboard ${tabs}`, Math.max(1, columns - 2))}${theme.border}|${theme.reset}\n`, - ); - output.write(`${theme.border}+${"-".repeat(leftBoxWidth - 2)}+ +${"-".repeat(rightBoxWidth - 2)}+${theme.reset}\n`); + let frame = "\x1b[2J\x1b[H"; + frame += `${theme.border}${border.tl}${border.h.repeat(Math.max(1, columns - 2))}${border.tr}${theme.reset}\n`; + if (isHeroEnabled()) { + frame += `${theme.border}${border.v}${theme.reset}${theme.hero}${buildHeroLine(uiState, session, Math.max(1, columns - 2))}${theme.reset}${theme.border}${border.v}${theme.reset}\n`; + } + frame += `${theme.border}${border.v}${theme.reset}${padText(` xint dashboard ${tabs}`, Math.max(1, columns - 2))}${theme.border}${border.v}${theme.reset}\n`; + frame += `${theme.border}${border.v}${theme.reset}${theme.accent}${padText(` ${tracker}`, Math.max(1, columns - 2))}${theme.reset}${theme.border}${border.v}${theme.reset}\n`; + frame += `${theme.border}${border.lj}${border.h.repeat(leftBoxWidth - 2)}${border.rj} ${border.lj}${border.h.repeat(rightBoxWidth - 2)}${border.rj}${theme.reset}\n`; for (let row = 0; row < totalRows; row += 1) { const leftRaw = leftLines[row] ?? ""; @@ -248,63 +355,60 @@ function renderDoublePane(uiState: UiState, session: SessionState, columns: numb ? `${theme.accent}${leftText}${theme.reset}` : `${theme.muted}${leftText}${theme.reset}`; - output.write( - `${theme.border}|${theme.reset}${leftSegment}${theme.border}|${theme.reset} ${theme.border}|${theme.reset}${theme.muted}${rightText}${theme.reset}${theme.border}|${theme.reset}\n`, - ); + frame += `${theme.border}${border.v}${theme.reset}${leftSegment}${theme.border}${border.v}${theme.reset} ${theme.border}${border.v}${theme.reset}${theme.muted}${rightText}${theme.reset}${theme.border}${border.v}${theme.reset}\n`; } - output.write(`${theme.border}+${"-".repeat(leftBoxWidth - 2)}+ +${"-".repeat(rightBoxWidth - 2)}+${theme.reset}\n`); - output.write( - `${theme.border}|${theme.reset}${theme.accent}${buildStatusLine(session, uiState, Math.max(1, columns - 2))}${theme.reset}${theme.border}|${theme.reset}\n`, - ); - const footer = " Up/Down Navigate | Enter Run | Tab Tabs | F Search Output | PgUp/PgDn Scroll | / Palette | q Quit "; - output.write(`${theme.border}|${theme.reset}${padText(footer, Math.max(1, columns - 2))}${theme.border}|${theme.reset}\n`); - output.write(`${theme.border}+${"-".repeat(Math.max(1, columns - 2))}+${theme.reset}\n`); + frame += `${theme.border}${border.lj}${border.h.repeat(leftBoxWidth - 2)}${border.rj} ${border.lj}${border.h.repeat(rightBoxWidth - 2)}${border.rj}${theme.reset}\n`; + frame += `${theme.border}${border.v}${theme.reset}${theme.accent}${buildStatusLine(session, uiState, Math.max(1, columns - 2))}${theme.reset}${theme.border}${border.v}${theme.reset}\n`; + const footer = " ↑↓ Move • Enter Run • Tab Views • f Filter • e Stream • / Palette • PgUp/PgDn Scroll • q Quit "; + frame += `${theme.border}${border.v}${theme.reset}${padText(footer, Math.max(1, columns - 2))}${theme.border}${border.v}${theme.reset}\n`; + frame += `${theme.border}${border.bl}${border.h.repeat(Math.max(1, columns - 2))}${border.br}${theme.reset}\n`; + output.write(frame); } function renderSinglePane(uiState: UiState, session: SessionState, columns: number, rows: number): void { const theme = activeTheme(); + const border = activeBorderChars(); const width = Math.max(30, columns - 2); - const totalRows = Math.max(10, rows - 7); - const tabs = (["commands", "output", "help"] as DashboardTab[]) - .map((tab, index) => { - const label = `${index + 1}:${tabLabel(tab)}`; - return tab === uiState.tab ? `${theme.accent}[ ${label} ]${theme.reset}` : `[ ${label} ]`; - }) - .join(" "); + const totalRows = Math.max(10, rows - (isHeroEnabled() ? 9 : 8)); + const tabs = buildTabs(uiState); + const tracker = buildHeaderTracker(uiState, 16); const lines = uiState.tab === "commands" ? [...buildMenuLines(uiState.activeIndex), "", ...buildCommandDrawer(uiState.activeIndex)] : buildTabLines(session, uiState, totalRows * 2); - output.write("\x1b[2J\x1b[H"); - output.write(`${theme.border}+${"-".repeat(width)}+${theme.reset}\n`); - output.write(`${theme.border}|${theme.reset}${padText(` xint dashboard ${tabs}`, width)}${theme.border}|${theme.reset}\n`); - output.write(`${theme.border}+${"-".repeat(width)}+${theme.reset}\n`); + let frame = "\x1b[2J\x1b[H"; + frame += `${theme.border}${border.tl}${border.h.repeat(width)}${border.tr}${theme.reset}\n`; + if (isHeroEnabled()) { + frame += `${theme.border}${border.v}${theme.reset}${theme.hero}${buildHeroLine(uiState, session, width)}${theme.reset}${theme.border}${border.v}${theme.reset}\n`; + } + frame += `${theme.border}${border.v}${theme.reset}${padText(` xint dashboard ${tabs}`, width)}${theme.border}${border.v}${theme.reset}\n`; + frame += `${theme.border}${border.v}${theme.reset}${theme.accent}${padText(` ${tracker}`, width)}${theme.reset}${theme.border}${border.v}${theme.reset}\n`; + frame += `${theme.border}${border.lj}${border.h.repeat(width)}${border.rj}${theme.reset}\n`; for (const line of lines.slice(-totalRows)) { const row = padText(line, width); if (line.startsWith("> ")) { - output.write(`${theme.border}|${theme.reset}${theme.accent}${row}${theme.reset}${theme.border}|${theme.reset}\n`); + frame += `${theme.border}${border.v}${theme.reset}${theme.accent}${row}${theme.reset}${theme.border}${border.v}${theme.reset}\n`; } else { - output.write(`${theme.border}|${theme.reset}${theme.muted}${row}${theme.reset}${theme.border}|${theme.reset}\n`); + frame += `${theme.border}${border.v}${theme.reset}${theme.muted}${row}${theme.reset}${theme.border}${border.v}${theme.reset}\n`; } } const rendered = Math.min(totalRows, lines.length); for (let i = rendered; i < totalRows; i += 1) { - output.write(`${theme.border}|${theme.reset}${" ".repeat(width)}${theme.border}|${theme.reset}\n`); + frame += `${theme.border}${border.v}${theme.reset}${" ".repeat(width)}${theme.border}${border.v}${theme.reset}\n`; } - const footer = " Tab Tabs | F Search Output | PgUp/PgDn Scroll | / Palette | q Quit "; - output.write(`${theme.border}+${"-".repeat(width)}+${theme.reset}\n`); - output.write( - `${theme.border}|${theme.reset}${theme.accent}${buildStatusLine(session, uiState, width)}${theme.reset}${theme.border}|${theme.reset}\n`, - ); - output.write(`${theme.border}+${"-".repeat(width)}+${theme.reset}\n`); - output.write(`${theme.border}|${theme.reset}${padText(footer, width)}${theme.border}|${theme.reset}\n`); - output.write(`${theme.border}+${"-".repeat(width)}+${theme.reset}\n`); + const footer = " Enter Run • Tab Views • f Filter • e Stream • / Palette • PgUp/PgDn • q Quit "; + frame += `${theme.border}${border.lj}${border.h.repeat(width)}${border.rj}${theme.reset}\n`; + frame += `${theme.border}${border.v}${theme.reset}${theme.accent}${buildStatusLine(session, uiState, width)}${theme.reset}${theme.border}${border.v}${theme.reset}\n`; + frame += `${theme.border}${border.lj}${border.h.repeat(width)}${border.rj}${theme.reset}\n`; + frame += `${theme.border}${border.v}${theme.reset}${padText(footer, width)}${theme.border}${border.v}${theme.reset}\n`; + frame += `${theme.border}${border.bl}${border.h.repeat(width)}${border.br}${theme.reset}\n`; + output.write(frame); } function renderDashboard(uiState: UiState, session: SessionState): void { @@ -317,12 +421,117 @@ function renderDashboard(uiState: UiState, session: SessionState): void { } } +function applyMenuKeyEvent( + str: string | undefined, + key: KeypressLike, + uiState: UiState, +): { resolve?: string } { + if (key.ctrl && key.name === "c") return { resolve: "0" }; + + if (key.name === "up") { + uiState.activeIndex = (uiState.activeIndex - 1 + INTERACTIVE_ACTIONS.length) % INTERACTIVE_ACTIONS.length; + return {}; + } + if (key.name === "down") { + uiState.activeIndex = (uiState.activeIndex + 1) % INTERACTIVE_ACTIONS.length; + return {}; + } + if (key.name === "tab") { + uiState.tab = nextTab(uiState.tab); + return {}; + } + if (key.name === "pageup" && uiState.tab === "output") { + uiState.outputOffset += 10; + return {}; + } + if (key.name === "pagedown" && uiState.tab === "output") { + uiState.outputOffset = Math.max(0, uiState.outputOffset - 10); + return {}; + } + if (key.name === "return") { + const selected = INTERACTIVE_ACTIONS[uiState.activeIndex]; + uiState.tab = "output"; + return { resolve: selected?.key ?? "0" }; + } + if (key.name === "escape" || str === "q") return { resolve: "0" }; + if (str === "?") { + uiState.tab = "help"; + return {}; + } + if (str === "1") { + uiState.tab = "commands"; + return {}; + } + if (str === "2") { + uiState.tab = "output"; + return {}; + } + if (str === "3") { + uiState.tab = "help"; + return {}; + } + if (str?.toLowerCase() === "f") { + uiState.tab = "output"; + return { resolve: "__filter__" }; + } + if (str?.toLowerCase() === "e") { + uiState.tab = "output"; + uiState.showStderr = !uiState.showStderr; + uiState.outputOffset = 0; + return {}; + } + if (str === "/") { + uiState.tab = "output"; + return { resolve: "__palette__" }; + } + + const normalized = normalizeInteractiveChoice(typeof str === "string" ? str : ""); + if (normalized) { + uiState.tab = "output"; + return { resolve: normalized }; + } + return {}; +} + +function applyPromptKeyEvent( + str: string | undefined, + key: KeypressLike, + uiState: UiState, +): { resolve?: string } { + if (key.ctrl && key.name === "c") return { resolve: "" }; + if (key.name === "escape") return { resolve: "" }; + if (key.name === "return") return { resolve: uiState.inlinePromptValue ?? "" }; + if (key.name === "backspace") { + uiState.inlinePromptValue = (uiState.inlinePromptValue ?? "").slice(0, -1); + return {}; + } + if (typeof str === "string" && str.length > 0 && !key.ctrl) { + uiState.inlinePromptValue = `${uiState.inlinePromptValue ?? ""}${str}`; + } + return {}; +} + +function applyRunEvent(event: RunEvent, session: SessionState): string | null { + if (event.type === "line") { + return null; + } + if (event.type === "tick") { + session.lastStatus = `running ${event.spinner}`; + return null; + } + const status = event.code === 0 ? "success" : `failed (exit ${event.code})`; + session.lastStatus = status; + return status; +} + async function selectOption( - rl: ReturnType, + rl: ReturnType | null, session: SessionState, uiState: UiState, + requestRender: () => void, ): Promise { if (!input.isTTY || !output.isTTY || typeof input.setRawMode !== "function") { + if (!rl) throw new Error("readline interface is unavailable"); output.write("\n=== xint interactive ===\n"); output.write("Type a number or alias.\n"); INTERACTIVE_ACTIONS.forEach((option) => { @@ -335,109 +544,40 @@ async function selectOption( emitKeypressEvents(input); return await new Promise((resolve) => { - const cleanup = () => { input.off("keypress", onKeypress); - input.setRawMode(false); }; const onKeypress = (str: string | undefined, key: { name?: string; ctrl?: boolean }) => { - if (key.ctrl && key.name === "c") { + const { resolve: resolved } = applyMenuKeyEvent(str, key, uiState); + if (resolved) { cleanup(); - resolve("0"); - return; - } - - if (key.name === "up") { - uiState.activeIndex = - (uiState.activeIndex - 1 + INTERACTIVE_ACTIONS.length) % INTERACTIVE_ACTIONS.length; - renderDashboard(uiState, session); - return; - } - if (key.name === "down") { - uiState.activeIndex = (uiState.activeIndex + 1) % INTERACTIVE_ACTIONS.length; - renderDashboard(uiState, session); - return; - } - if (key.name === "tab") { - uiState.tab = nextTab(uiState.tab); - renderDashboard(uiState, session); + resolve(resolved); return; } - if (key.name === "pageup" && uiState.tab === "output") { - uiState.outputOffset += 10; - renderDashboard(uiState, session); - return; - } - if (key.name === "pagedown" && uiState.tab === "output") { - uiState.outputOffset = Math.max(0, uiState.outputOffset - 10); - renderDashboard(uiState, session); - return; - } - if (key.name === "return") { - const selected = INTERACTIVE_ACTIONS[uiState.activeIndex]; - uiState.tab = "output"; - cleanup(); - resolve(selected?.key ?? "0"); - return; - } - if (key.name === "escape" || str === "q") { - cleanup(); - output.write("\x1b[2J\x1b[H"); - resolve("0"); - return; - } - if (str === "?") { - uiState.tab = "help"; - renderDashboard(uiState, session); - return; - } - if (str === "1") { - uiState.tab = "commands"; - renderDashboard(uiState, session); - return; - } - if (str === "2") { - uiState.tab = "output"; - renderDashboard(uiState, session); - return; - } - if (str === "3") { - uiState.tab = "help"; - renderDashboard(uiState, session); - return; - } - if (str?.toLowerCase() === "f") { - uiState.tab = "output"; - cleanup(); - resolve("__filter__"); - return; - } - if (str === "/") { - uiState.tab = "output"; - cleanup(); - resolve("__palette__"); - return; - } - - const normalized = normalizeInteractiveChoice(typeof str === "string" ? str : ""); - if (normalized) { - uiState.tab = "output"; - cleanup(); - resolve(normalized); - } + requestRender(); }; - input.setRawMode(true); input.resume(); input.on("keypress", onKeypress); - renderDashboard(uiState, session); + requestRender(); }); } -function appendOutput(session: SessionState, line: string): void { - const trimmed = line.trimEnd(); +function appendOutput(session: SessionState, line: string, stream: "stdout" | "stderr" = "stdout"): void { + const trimmed = sanitizeOutputLine(line).trimEnd(); if (!trimmed) return; + if (stream === "stderr") { + session.lastStderrLines.push(trimmed); + if (session.lastStderrLines.length > 1200) { + session.lastStderrLines = session.lastStderrLines.slice(-1200); + } + } else { + session.lastStdoutLines.push(trimmed); + if (session.lastStdoutLines.length > 1200) { + session.lastStdoutLines = session.lastStdoutLines.slice(-1200); + } + } session.lastOutputLines.push(trimmed); if (session.lastOutputLines.length > 1200) { session.lastOutputLines = session.lastOutputLines.slice(-1200); @@ -447,8 +587,7 @@ function appendOutput(session: SessionState, line: string): void { async function consumeStream( stream: ReadableStream | null, prefix: string, - session: SessionState, - uiState: UiState, + onLine: (line: string) => void, ): Promise { if (!stream) return; const reader = stream.getReader(); @@ -459,18 +598,16 @@ async function consumeStream( const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); + buffer = buffer.replace(/\r(?!\n)/g, "\n"); const parts = buffer.split(/\r?\n/); buffer = parts.pop() ?? ""; for (const part of parts) { - appendOutput(session, prefix ? `[${prefix}] ${part}` : part); - if (input.isTTY && output.isTTY) { - renderDashboard(uiState, session); - } + onLine(prefix ? `[${prefix}] ${part}` : part); } } if (buffer.trim().length > 0) { - appendOutput(session, prefix ? `[${prefix}] ${buffer}` : buffer); + onLine(prefix ? `[${prefix}] ${buffer}` : buffer); } } @@ -478,6 +615,7 @@ async function runSubcommand( args: string[], session: SessionState, uiState: UiState, + requestRender: () => void, ): Promise<{ status: string; outputLines: string[] }> { const scriptPath = join(import.meta.dir, "..", "xint.ts"); const proc = Bun.spawn({ @@ -487,26 +625,40 @@ async function runSubcommand( }); session.lastOutputLines = []; + session.lastStdoutLines = []; + session.lastStderrLines = []; uiState.outputOffset = 0; const spinnerFrames = ["|", "/", "-", "\\"]; let spinnerIndex = 0; - const spinner = setInterval(() => { - session.lastStatus = `running ${spinnerFrames[spinnerIndex % spinnerFrames.length]}`; - spinnerIndex += 1; + let finalStatus: string | null = null; + const dispatch = (event: RunEvent) => { + const status = applyRunEvent(event, session); + if (status) finalStatus = status; if (input.isTTY && output.isTTY) { - renderDashboard(uiState, session); + requestRender(); } + }; + + const spinner = setInterval(() => { + dispatch({ type: "tick", spinner: spinnerFrames[spinnerIndex % spinnerFrames.length] }); + spinnerIndex += 1; }, 90); - const stdoutTask = consumeStream(proc.stdout ?? null, "", session, uiState); - const stderrTask = consumeStream(proc.stderr ?? null, "stderr", session, uiState); + const stdoutTask = consumeStream(proc.stdout ?? null, "", (line) => { + appendOutput(session, line, "stdout"); + if (input.isTTY && output.isTTY) requestRender(); + }); + const stderrTask = consumeStream(proc.stderr ?? null, "stderr", (line) => { + appendOutput(session, line, "stderr"); + if (input.isTTY && output.isTTY) requestRender(); + }); const exitCode = await proc.exited; await Promise.all([stdoutTask, stderrTask]); clearInterval(spinner); - - const status = exitCode === 0 ? "success" : `failed (exit ${exitCode})`; + dispatch({ type: "exit", code: exitCode }); + const status = finalStatus ?? (exitCode === 0 ? "success" : `failed (exit ${exitCode})`); return { status, outputLines: session.lastOutputLines.slice(-1200) }; } @@ -523,12 +675,14 @@ function promptWithDefault(value: string, previous?: string): string { } async function questionInDashboard( - rl: ReturnType, + rl: ReturnType | null, label: string, uiState: UiState, session: SessionState, + requestRender: () => void, ): Promise { if (!input.isTTY || !output.isTTY || typeof input.setRawMode !== "function") { + if (!rl) throw new Error("readline interface is unavailable"); return await rl.question(`\n${label}`); } @@ -536,73 +690,95 @@ async function questionInDashboard( uiState.tab = "output"; uiState.inlinePromptLabel = label; uiState.inlinePromptValue = ""; - renderDashboard(uiState, session); + requestRender(); return await new Promise((resolve) => { const cleanup = () => { input.off("keypress", onKeypress); - input.setRawMode(false); uiState.inlinePromptLabel = undefined; uiState.inlinePromptValue = undefined; - renderDashboard(uiState, session); + requestRender(); }; const onKeypress = (str: string | undefined, key: { name?: string; ctrl?: boolean }) => { - if (key.ctrl && key.name === "c") { - cleanup(); - resolve(""); - return; - } - if (key.name === "escape") { + const { resolve: resolved } = applyPromptKeyEvent(str, key, uiState); + if (resolved !== undefined) { cleanup(); - resolve(""); - return; - } - if (key.name === "return") { - const value = uiState.inlinePromptValue ?? ""; - cleanup(); - resolve(value); - return; - } - if (key.name === "backspace") { - uiState.inlinePromptValue = (uiState.inlinePromptValue ?? "").slice(0, -1); - renderDashboard(uiState, session); + resolve(resolved); return; } - if (typeof str === "string" && str.length > 0 && !key.ctrl) { - uiState.inlinePromptValue = `${uiState.inlinePromptValue ?? ""}${str}`; - renderDashboard(uiState, session); - } + requestRender(); }; - input.setRawMode(true); input.resume(); input.on("keypress", onKeypress); }); } export async function cmdTui(): Promise { + const useRawTui = input.isTTY && output.isTTY && typeof input.setRawMode === "function"; + const frameIntervalMs = 33; + let scheduledRender: ReturnType | null = null; + let lastRenderAt = 0; const initialIndex = INTERACTIVE_ACTIONS.findIndex((option) => option.key === "1"); const uiState: UiState = { activeIndex: initialIndex >= 0 ? initialIndex : 0, tab: "output", outputOffset: 0, outputSearch: "", + showStderr: false, }; const session: SessionState = { + lastStdoutLines: [], + lastStderrLines: [], lastOutputLines: [], }; - const rl = createInterface({ input, output }); + const requestRender = (force = false) => { + if (!useRawTui) return; + const now = Date.now(); + const elapsed = now - lastRenderAt; + if (force || elapsed >= frameIntervalMs) { + if (scheduledRender) { + clearTimeout(scheduledRender); + scheduledRender = null; + } + lastRenderAt = now; + renderDashboard(uiState, session); + return; + } + if (scheduledRender) return; + scheduledRender = setTimeout(() => { + scheduledRender = null; + lastRenderAt = Date.now(); + renderDashboard(uiState, session); + }, frameIntervalMs - elapsed); + }; + const onResize = () => requestRender(true); + const rl = useRawTui ? null : createInterface({ input, output }); try { + if (useRawTui) { + emitKeypressEvents(input); + input.setRawMode(true); + input.resume(); + output.write("\x1b[?1049h\x1b[?25l"); + output.on("resize", onResize); + } + for (;;) { - let choice = await selectOption(rl, session, uiState); + let choice = await selectOption(rl, session, uiState, () => requestRender()); if (choice === "0") { - console.log("Exiting xint interactive mode."); break; } if (choice === "__filter__") { - const query = await questionInDashboard(rl, "Output search (blank clears): ", uiState, session); + const query = await questionInDashboard( + rl, + "Output search (blank clears): ", + uiState, + session, + () => requestRender(), + ); + requestRender(true); uiState.outputSearch = query.trim(); uiState.outputOffset = 0; uiState.tab = "output"; @@ -612,7 +788,7 @@ export async function cmdTui(): Promise { continue; } if (choice === "__palette__") { - const query = await questionInDashboard(rl, "Palette (/): ", uiState, session); + const query = await questionInDashboard(rl, "Palette (/): ", uiState, session, () => requestRender()); const match = matchPalette(query); if (!match) { session.lastStatus = `no palette match: ${query.trim() || "(empty)"}`; @@ -637,6 +813,7 @@ export async function cmdTui(): Promise { `Search query${session.lastSearch ? ` [${session.lastSearch}]` : ""}: `, uiState, session, + () => requestRender(), ), session.lastSearch, ), @@ -646,26 +823,27 @@ export async function cmdTui(): Promise { const planResult = buildTuiExecutionPlan(choice, query); if (planResult.type === "error" || !planResult.data) throw new Error(planResult.message); session.lastCommand = planResult.data.command; - const result = await runSubcommand(planResult.data.args, session, uiState); + const result = await runSubcommand(planResult.data.args, session, uiState, () => requestRender()); session.lastStatus = result.status; session.lastOutputLines = result.outputLines; break; } case "2": { const location = promptWithDefault( - await questionInDashboard( - rl, - `Location (blank for worldwide)${session.lastLocation ? ` [${session.lastLocation}]` : ""}: `, - uiState, - session, - ), + await questionInDashboard( + rl, + `Location (blank for worldwide)${session.lastLocation ? ` [${session.lastLocation}]` : ""}: `, + uiState, + session, + () => requestRender(), + ), session.lastLocation, ); session.lastLocation = location; const planResult = buildTuiExecutionPlan(choice, location); if (planResult.type === "error" || !planResult.data) throw new Error(planResult.message); session.lastCommand = planResult.data.command; - const result = await runSubcommand(planResult.data.args, session, uiState); + const result = await runSubcommand(planResult.data.args, session, uiState, () => requestRender()); session.lastStatus = result.status; session.lastOutputLines = result.outputLines; break; @@ -678,6 +856,7 @@ export async function cmdTui(): Promise { `Username (@optional)${session.lastUsername ? ` [${session.lastUsername}]` : ""}: `, uiState, session, + () => requestRender(), ), session.lastUsername, ), @@ -687,7 +866,7 @@ export async function cmdTui(): Promise { const planResult = buildTuiExecutionPlan(choice, username); if (planResult.type === "error" || !planResult.data) throw new Error(planResult.message); session.lastCommand = planResult.data.command; - const result = await runSubcommand(planResult.data.args, session, uiState); + const result = await runSubcommand(planResult.data.args, session, uiState, () => requestRender()); session.lastStatus = result.status; session.lastOutputLines = result.outputLines; break; @@ -700,6 +879,7 @@ export async function cmdTui(): Promise { `Tweet ID or URL${session.lastTweetRef ? ` [${session.lastTweetRef}]` : ""}: `, uiState, session, + () => requestRender(), ), session.lastTweetRef, ), @@ -709,7 +889,7 @@ export async function cmdTui(): Promise { const planResult = buildTuiExecutionPlan(choice, tweetRef); if (planResult.type === "error" || !planResult.data) throw new Error(planResult.message); session.lastCommand = planResult.data.command; - const result = await runSubcommand(planResult.data.args, session, uiState); + const result = await runSubcommand(planResult.data.args, session, uiState, () => requestRender()); session.lastStatus = result.status; session.lastOutputLines = result.outputLines; break; @@ -722,6 +902,7 @@ export async function cmdTui(): Promise { `Article URL or Tweet URL${session.lastArticleUrl ? ` [${session.lastArticleUrl}]` : ""}: `, uiState, session, + () => requestRender(), ), session.lastArticleUrl, ), @@ -731,7 +912,7 @@ export async function cmdTui(): Promise { const planResult = buildTuiExecutionPlan(choice, url); if (planResult.type === "error" || !planResult.data) throw new Error(planResult.message); session.lastCommand = planResult.data.command; - const result = await runSubcommand(planResult.data.args, session, uiState); + const result = await runSubcommand(planResult.data.args, session, uiState, () => requestRender()); session.lastStatus = result.status; session.lastOutputLines = result.outputLines; break; @@ -740,7 +921,7 @@ export async function cmdTui(): Promise { const planResult = buildTuiExecutionPlan(choice); if (planResult.type === "error" || !planResult.data) throw new Error(planResult.message); session.lastCommand = planResult.data.command; - const result = await runSubcommand(planResult.data.args, session, uiState); + const result = await runSubcommand(planResult.data.args, session, uiState, () => requestRender()); session.lastStatus = result.status; session.lastOutputLines = result.outputLines; break; @@ -755,6 +936,18 @@ export async function cmdTui(): Promise { } } } finally { - rl.close(); + if (useRawTui) { + if (scheduledRender) clearTimeout(scheduledRender); + output.off("resize", onResize); + input.setRawMode(false); + output.write("\x1b[?25h\x1b[?1049l"); + } + rl?.close(); } } + +export const __tuiTestUtils = { + applyMenuKeyEvent, + outputViewLines, + sanitizeOutputLine, +}; diff --git a/lib/tui_adapter.test.ts b/lib/tui_adapter.test.ts index 07d2c31..b78c8d7 100644 --- a/lib/tui_adapter.test.ts +++ b/lib/tui_adapter.test.ts @@ -11,6 +11,15 @@ describe("tui adapter", () => { }); }); + test("normalizes ampersand in search query", () => { + const result = buildTuiExecutionPlan("1", "ai & solana"); + expect(result.type).toBe("success"); + expect(result.data).toEqual({ + command: "xint search ai AND solana", + args: ["search", "ai AND solana"], + }); + }); + test("builds trends plan with blank input", () => { const result = buildTuiExecutionPlan("2", ""); expect(result.type).toBe("success"); diff --git a/lib/tui_adapter.ts b/lib/tui_adapter.ts index b2903ad..4ffa1cf 100644 --- a/lib/tui_adapter.ts +++ b/lib/tui_adapter.ts @@ -5,6 +5,13 @@ export type TuiExecutionPlan = { args: string[]; }; +function normalizeSearchQuery(value: string): string { + return value + .replace(/\s*&\s*/g, " AND ") + .replace(/\s+/g, " ") + .trim(); +} + export function buildTuiExecutionPlan( actionKey: string, value?: string, @@ -14,9 +21,10 @@ export function buildTuiExecutionPlan( switch (actionKey) { case "1": if (!normalized) return actionError("Query is required."); + const searchQuery = normalizeSearchQuery(normalized); return actionSuccess("Search plan ready.", { - command: `xint search ${normalized}`, - args: ["search", normalized], + command: `xint search ${searchQuery}`, + args: ["search", searchQuery], }); case "2": if (!normalized) { diff --git a/package.json b/package.json index e2ddb77..5bcfede 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "xint", - "version": "2026.2.19.7", + "version": "2026.2.20.2", "author": "Nyk", "repository": { "type": "git", diff --git a/scripts/release.sh b/scripts/release.sh index 5ce33ab..e2ef81d 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -6,9 +6,11 @@ set -euo pipefail REPO_NAME="xint" REPO_NAME_ALT="xint-rs" +REPO_NAME_CLOUD="xint-cloud" GITHUB_ORG="0xNyk" PUBLISH_CLAWDHUB=true +PUBLISH_CLAWDHUB_CLOUD=false PUBLISH_SKILLSH=false PUBLISH_HOMEBREW=true UPDATE_DOCS=false @@ -32,6 +34,7 @@ fi ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" REPO_PATH_XINT="${REPO_PATH_XINT:-}" REPO_PATH_XINT_RS="${REPO_PATH_XINT_RS:-}" +REPO_PATH_XINT_CLOUD="${REPO_PATH_XINT_CLOUD:-}" REPO_PATH_HOMEBREW="${REPO_PATH_HOMEBREW:-}" HOMEBREW_TAP_REPO="homebrew-xint" REPORT_DIR="${RELEASE_REPORT_DIR:-$ROOT_DIR/reports/releases}" @@ -48,6 +51,7 @@ Options: --dry-run Preview release actions without mutating repos --ai-skill Enable both ClawdHub and skills.sh publishing --no-clawdhub Disable ClawdHub publishing for this run + --clawdhub-cloud Enable ClawdHub publishing for xint-cloud (disabled by default) --skillsh Enable skills.sh publishing --no-homebrew Disable Homebrew tap formula update/publish --docs Update README/changelog files when present @@ -69,6 +73,9 @@ Environment variables: CHANGELOG_SECURITY TWEET_DRAFT RELEASE_REPORT_DIR + REPO_PATH_XINT + REPO_PATH_XINT_RS + REPO_PATH_XINT_CLOUD REPO_PATH_HOMEBREW USAGE } @@ -116,6 +123,9 @@ resolve_repo_path() { "$REPO_NAME_ALT") override="$REPO_PATH_XINT_RS" ;; + "$REPO_NAME_CLOUD") + override="$REPO_PATH_XINT_CLOUD" + ;; esac if [[ -n "$override" && -d "$override/.git" ]]; then @@ -665,6 +675,27 @@ update_homebrew_formula_file() { mv "$tmp_file" "$formula_file" } +url_exists() { + local url="$1" + curl -fsSI "$url" >/dev/null 2>&1 +} + +verify_homebrew_release_assets() { + local binary_url="$1" + local source_url="$2" + local release_page="https://github.com/$GITHUB_ORG/$REPO_NAME_ALT/releases/tag/$VERSION" + + if ! url_exists "$binary_url"; then + die "Missing Homebrew binary artifact: $binary_url +Upload xint-rs-macos-arm64-$VERSION.tar.gz to $release_page before running Homebrew publish." + fi + + if ! url_exists "$source_url"; then + die "Missing Homebrew source artifact: $source_url +Ensure tag $VERSION exists and the source archive is available before Homebrew publish." + fi +} + publish_homebrew_tap() { local tap_path formula_xint formula_xint_rs local binary_url source_url binary_sha source_sha @@ -707,6 +738,8 @@ publish_homebrew_tap() { binary_sha="" source_sha="" else + log "Verifying required Homebrew release assets exist" + verify_homebrew_release_assets "$binary_url" "$source_url" log "Computing Homebrew SHA256 for release artifacts" binary_sha="$(sha256_for_url "$binary_url")" || die "Failed to download/hash binary artifact: $binary_url" source_sha="$(sha256_for_url "$source_url")" || die "Failed to download/hash source artifact: $source_url" @@ -923,8 +956,10 @@ EOF generate_release_report() { local previous_tag_primary="$1" local previous_tag_alt="$2" - local release_url_primary="$3" - local release_url_alt="$4" + local previous_tag_cloud="$3" + local release_url_primary="$4" + local release_url_alt="$5" + local release_url_cloud="$6" local report_file report_file="$REPORT_DIR/$VERSION.md" GENERATED_REPORT_FILE="" @@ -957,6 +992,9 @@ EOF if [[ -n "$REPO_NAME_ALT" ]]; then append_repo_release_section "$report_file" "$REPO_NAME_ALT" "$previous_tag_alt" "$release_url_alt" fi + if [[ -n "$REPO_NAME_CLOUD" ]]; then + append_repo_release_section "$report_file" "$REPO_NAME_CLOUD" "$previous_tag_cloud" "$release_url_cloud" + fi GENERATED_REPORT_FILE="$report_file" log "Release report generated: $report_file" @@ -974,6 +1012,9 @@ while [[ $# -gt 0 ]]; do --no-clawdhub) PUBLISH_CLAWDHUB=false ;; + --clawdhub-cloud) + PUBLISH_CLAWDHUB_CLOUD=true + ;; --skillsh) PUBLISH_SKILLSH=true ;; @@ -1033,6 +1074,9 @@ repo_exists "$REPO_NAME" || die "Missing repo directory: $REPO_NAME" if [[ -n "$REPO_NAME_ALT" ]]; then repo_exists "$REPO_NAME_ALT" || die "Missing repo directory: $REPO_NAME_ALT" fi +if [[ -n "$REPO_NAME_CLOUD" ]]; then + repo_exists "$REPO_NAME_CLOUD" || die "Missing repo directory: $REPO_NAME_CLOUD" +fi if [[ -z "$VERSION" ]]; then VERSION="$(detect_next_version)" @@ -1042,35 +1086,52 @@ log "Preparing release version: $VERSION" PREVIOUS_TAG_PRIMARY="$(find_previous_release_tag "$REPO_NAME" "$VERSION")" PREVIOUS_TAG_ALT="" +PREVIOUS_TAG_CLOUD="" if [[ -n "$REPO_NAME_ALT" ]]; then PREVIOUS_TAG_ALT="$(find_previous_release_tag "$REPO_NAME_ALT" "$VERSION")" fi +if [[ -n "$REPO_NAME_CLOUD" ]]; then + PREVIOUS_TAG_CLOUD="$(find_previous_release_tag "$REPO_NAME_CLOUD" "$VERSION")" +fi preflight_repo "$REPO_NAME" if [[ -n "$REPO_NAME_ALT" ]]; then preflight_repo "$REPO_NAME_ALT" fi +if [[ -n "$REPO_NAME_CLOUD" ]]; then + preflight_repo "$REPO_NAME_CLOUD" +fi log "Bumping manifest versions" declare -a RELEASE_FILES_PRIMARY declare -a RELEASE_FILES_ALT +declare -a RELEASE_FILES_CLOUD collect_release_files "$REPO_NAME" RELEASE_FILES_PRIMARY if [[ -n "$REPO_NAME_ALT" ]]; then collect_release_files "$REPO_NAME_ALT" RELEASE_FILES_ALT fi +if [[ -n "$REPO_NAME_CLOUD" ]]; then + collect_release_files "$REPO_NAME_CLOUD" RELEASE_FILES_CLOUD +fi log "Committing release manifests" commit_repo "$REPO_NAME" "${RELEASE_FILES_PRIMARY[@]}" if [[ -n "$REPO_NAME_ALT" ]]; then commit_repo "$REPO_NAME_ALT" "${RELEASE_FILES_ALT[@]}" fi +if [[ -n "$REPO_NAME_CLOUD" ]]; then + commit_repo "$REPO_NAME_CLOUD" "${RELEASE_FILES_CLOUD[@]}" +fi log "Pushing release commits" push_repo "$REPO_NAME" if [[ -n "$REPO_NAME_ALT" ]]; then push_repo "$REPO_NAME_ALT" fi +if [[ -n "$REPO_NAME_CLOUD" ]]; then + push_repo "$REPO_NAME_CLOUD" +fi if [[ "$PUBLISH_CLAWDHUB" == "true" ]]; then log "Publishing to ClawdHub" @@ -1078,6 +1139,9 @@ if [[ "$PUBLISH_CLAWDHUB" == "true" ]]; then if [[ -n "$REPO_NAME_ALT" ]]; then publish_clawdhub "$REPO_NAME_ALT" fi + if [[ -n "$REPO_NAME_CLOUD" && "$PUBLISH_CLAWDHUB_CLOUD" == "true" ]]; then + publish_clawdhub "$REPO_NAME_CLOUD" + fi fi if [[ "$PUBLISH_SKILLSH" == "true" ]]; then @@ -1086,6 +1150,9 @@ if [[ "$PUBLISH_SKILLSH" == "true" ]]; then if [[ -n "$REPO_NAME_ALT" ]]; then publish_skillsh "$REPO_NAME_ALT" fi + if [[ -n "$REPO_NAME_CLOUD" ]]; then + publish_skillsh "$REPO_NAME_CLOUD" + fi fi CUSTOM_NOTES=false @@ -1123,37 +1190,54 @@ create_github_release "$REPO_NAME" "$RELEASE_NOTES" "$USE_AUTO_NOTES" if [[ -n "$REPO_NAME_ALT" ]]; then create_github_release "$REPO_NAME_ALT" "$RELEASE_NOTES" "$USE_AUTO_NOTES" fi +if [[ -n "$REPO_NAME_CLOUD" ]]; then + create_github_release "$REPO_NAME_CLOUD" "$RELEASE_NOTES" "$USE_AUTO_NOTES" +fi log "Publishing Homebrew tap formulas" publish_homebrew_tap RELEASE_URL_PRIMARY="$(release_url_for_repo "$REPO_NAME")" RELEASE_URL_ALT="" +RELEASE_URL_CLOUD="" if [[ -n "$REPO_NAME_ALT" ]]; then RELEASE_URL_ALT="$(release_url_for_repo "$REPO_NAME_ALT")" fi +if [[ -n "$REPO_NAME_CLOUD" ]]; then + RELEASE_URL_CLOUD="$(release_url_for_repo "$REPO_NAME_CLOUD")" +fi generate_release_report \ "$PREVIOUS_TAG_PRIMARY" \ "$PREVIOUS_TAG_ALT" \ + "$PREVIOUS_TAG_CLOUD" \ "$RELEASE_URL_PRIMARY" \ - "$RELEASE_URL_ALT" + "$RELEASE_URL_ALT" \ + "$RELEASE_URL_CLOUD" upload_release_report_asset "$REPO_NAME" "$GENERATED_REPORT_FILE" if [[ -n "$REPO_NAME_ALT" ]]; then upload_release_report_asset "$REPO_NAME_ALT" "$GENERATED_REPORT_FILE" fi +if [[ -n "$REPO_NAME_CLOUD" ]]; then + upload_release_report_asset "$REPO_NAME_CLOUD" "$GENERATED_REPORT_FILE" +fi embed_release_report_in_body "$REPO_NAME" "$GENERATED_REPORT_FILE" if [[ -n "$REPO_NAME_ALT" ]]; then embed_release_report_in_body "$REPO_NAME_ALT" "$GENERATED_REPORT_FILE" fi +if [[ -n "$REPO_NAME_CLOUD" ]]; then + embed_release_report_in_body "$REPO_NAME_CLOUD" "$GENERATED_REPORT_FILE" +fi if [[ -z "${TWEET_DRAFT:-}" ]]; then if [[ "$USE_AUTO_NOTES" == "true" ]]; then TWEET_DRAFT="xint $VERSION is available. -See GitHub release notes for details." +$RELEASE_URL_PRIMARY +$RELEASE_URL_ALT +$RELEASE_URL_CLOUD" else TWEET_DRAFT="xint $VERSION is available. diff --git a/tui-theme.tokens.example.json b/tui-theme.tokens.example.json new file mode 100644 index 0000000..c5b8e38 --- /dev/null +++ b/tui-theme.tokens.example.json @@ -0,0 +1,7 @@ +{ + "accent": "\u001b[1;96m", + "border": "\u001b[38;5;39m", + "muted": "\u001b[38;5;244m", + "hero": "\u001b[1;94m", + "reset": "\u001b[0m" +}