@@ -21,6 +26,11 @@
Live captions
+
+
diff --git a/aai_cli/init/templates/live-captions/static/styles.css b/aai_cli/init/templates/live-captions/static/styles.css
index 7fe51e1e..93e8dec3 100644
--- a/aai_cli/init/templates/live-captions/static/styles.css
+++ b/aai_cli/init/templates/live-captions/static/styles.css
@@ -1,37 +1,81 @@
-/* THEME TOKENS: edit this block first for monotone colors, spacing, radii, and width. */
+/* THEME TOKENS — AssemblyAI design system. Edit this block first to retheme.
+ Brand fonts hotlink from assemblyai.com (CORS-open) with system fallbacks. */
+@font-face {
+ font-family: "Oceanic Text";
+ src: url("https://www.assemblyai.com/_aai/fonts/rebrand/OceanicText-Regular.otf")
+ format("opentype");
+ font-weight: 400;
+ font-display: swap;
+}
+@font-face {
+ font-family: "UN 11ST";
+ src: url("https://www.assemblyai.com/_aai/fonts/rebrand/UN_11ST_Regular.woff2")
+ format("woff2");
+ font-weight: 400;
+ font-display: swap;
+}
+@font-face {
+ font-family: "UN 11ST";
+ src: url("https://www.assemblyai.com/_aai/fonts/rebrand/UN_11ST_Bold.woff2")
+ format("woff2");
+ font-weight: 700;
+ font-display: swap;
+}
+@font-face {
+ font-family: "Modern Gothic Mono";
+ src: url("https://www.assemblyai.com/_aai/fonts/rebrand/ModernGothicMono-Regular.woff2")
+ format("woff2");
+ font-weight: 400;
+ font-display: swap;
+}
+
:root {
- --font-sans:
- -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
- --background: oklch(0.985 0 0);
- --foreground: oklch(0.12 0 0);
- --card: oklch(1 0 0);
- --primary: oklch(0.12 0 0);
- --primary-foreground: oklch(0.985 0 0);
- --secondary: oklch(0.965 0 0);
- --secondary-foreground: oklch(0.38 0 0);
- --muted-foreground: oklch(0.58 0 0);
- --border: oklch(0.9 0 0);
- --ring: oklch(0.5 0 0);
- --shadow-panel: 0 1px 3px oklch(0 0 0 / 0.05), 0 1px 1px oklch(0 0 0 / 0.03);
- --shadow-focus:
- 0 0 0 1px oklch(0 0 0 / 0.08), 0 2px 8px -2px oklch(0 0 0 / 0.08);
- --color-bg: var(--background);
- --color-surface: var(--card);
- --color-text: var(--foreground);
- --color-text-muted: var(--muted-foreground);
- --color-border: var(--border);
- --color-accent: var(--primary);
- --color-accent-contrast: var(--primary-foreground);
- --color-danger: var(--foreground);
- --color-danger-strong: var(--foreground);
- --color-danger-soft: var(--secondary);
- --color-neutral: var(--secondary-foreground);
- --color-neutral-soft: var(--secondary);
- --radius-panel: 8px;
+ /* Brand fonts */
+ --font-display: "Oceanic Text", Georgia, serif;
+ --font-body: "UN 11ST", system-ui, sans-serif;
+ --font-mono: "Modern Gothic Mono", "JetBrains Mono", monospace;
+
+ /* Cobolt (brand purple — the only accent) */
+ --color-cobolt-500: #3923c7;
+ --color-cobolt-300: #887bdd;
+ --color-cobolt-200: #b0a7e9;
+ --color-cobolt-100: #d7d3f4;
+
+ /* Warm neutrals */
+ --color-black-500: #1d1b16;
+ --color-black-400: #4a4945;
+ --color-black-300: #777673;
+ --color-white-100: #ffffff;
+ --color-white-200: #fdfcf8;
+ --color-white-300: #f5f3eb;
+ --color-neutral-100: #ecebe5;
+ --color-neutral-200: #dad7cb;
+ --color-neutral-300: #c7c3b2;
+
+ /* Blush (error highlight only) */
+ --color-blush-200: #f4d4d0;
+ --color-blush-500: #e39389;
+
+ /* Semantic aliases — downstream rules use these */
+ --color-bg: var(--color-white-200);
+ --color-surface: var(--color-white-100);
+ --color-text: var(--color-black-400);
+ --color-text-dark: var(--color-black-500);
+ --color-text-muted: var(--color-black-300);
+ --color-border: var(--color-neutral-300);
+ --color-accent: var(--color-cobolt-500);
+ --color-accent-hover: var(--color-cobolt-300);
+ --color-accent-contrast: var(--color-white-100);
+ --color-live: var(--color-black-500);
+
+ --shadow-focus: 0 0 0 3px var(--color-cobolt-100);
+
+ --radius-cta: 4px;
--radius-control: 8px;
- --radius-pill: 999px;
- --space-page-block: 48px;
- --space-page-inline: 16px;
+ --radius-panel: 12px;
+ --radius-pill: 9999px;
+ --space-page-block: 64px;
+ --space-page-inline: 24px;
--content-width: 720px;
}
@@ -46,12 +90,24 @@ body {
padding: var(--space-page-block) var(--space-page-inline);
background: var(--color-bg);
color: var(--color-text);
- font: 15px/1.6 var(--font-sans);
+ font: 16px/1.3 var(--font-body);
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+ font-family: var(--font-display);
+ font-weight: 400;
+ line-height: 1;
+ color: var(--color-text-dark);
+}
+
button {
appearance: none;
font: inherit;
@@ -63,22 +119,78 @@ button {
margin: 0 auto;
}
+/* BRAND CHROME */
+.brand {
+ display: inline-flex;
+ align-items: center;
+ margin-bottom: 40px;
+}
+
+.brand-logo {
+ display: block;
+ width: auto;
+ height: 20px;
+}
+
+.eyebrow {
+ display: inline-flex;
+ align-items: center;
+ margin-bottom: 16px;
+ padding: 8px 16px;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-cta);
+ color: var(--color-black-400);
+ font-family: var(--font-mono);
+ font-size: 12px;
+ letter-spacing: 1.2px;
+ text-transform: uppercase;
+ font-feature-settings: "ss09" 1;
+}
+
.page-header {
- margin-bottom: 24px;
+ margin-bottom: 32px;
}
.page-title {
- margin: 0 0 4px;
- font-size: 22px;
- line-height: 1.25;
+ margin: 0 0 12px;
+ font-size: 48px;
+ letter-spacing: -2.4px;
}
.page-subtitle {
- max-width: 68ch;
+ max-width: 60ch;
margin: 0;
+ font-size: 18px;
color: var(--color-text-muted);
}
+.page-footer {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-top: 64px;
+ padding-top: 24px;
+ border-top: 1px solid var(--color-border);
+ color: var(--color-text-muted);
+ font-size: 14px;
+}
+
+.footer-link {
+ color: var(--color-accent);
+ font-family: var(--font-mono);
+ font-size: 12px;
+ letter-spacing: 1.2px;
+ text-transform: uppercase;
+ text-decoration: none;
+ transition: color 0.2s ease;
+}
+
+.footer-link:hover {
+ color: var(--color-accent-hover);
+}
+
.control-bar {
display: flex;
align-items: center;
@@ -91,22 +203,25 @@ button {
--button-bg: var(--color-accent);
--button-fg: var(--color-accent-contrast);
display: inline-flex;
- min-height: 44px;
+ min-height: 40px;
align-items: center;
justify-content: center;
border: 0;
- border-radius: var(--radius-control);
+ border-radius: var(--radius-cta);
background: var(--button-bg);
color: var(--button-fg);
cursor: pointer;
+ font-family: var(--font-mono);
font-size: 14px;
- font-weight: 600;
- padding: 0 20px;
+ letter-spacing: 1.4px;
+ text-transform: uppercase;
+ padding: 0 18px;
white-space: nowrap;
+ transition: background-color 0.2s ease;
}
.button:hover:not(:disabled) {
- filter: brightness(0.96);
+ --button-bg: var(--color-accent-hover);
}
.button:focus-visible {
@@ -120,57 +235,76 @@ button {
}
.record-button[data-state="recording"] {
- --button-bg: var(--color-danger-strong);
+ --button-bg: var(--color-live);
+}
+
+.record-button[data-state="recording"]:hover:not(:disabled) {
+ --button-bg: var(--color-black-400);
}
/* STATUS STATES */
.status-pill {
- display: inline-block;
+ display: inline-flex;
+ align-items: center;
+ border: 1px solid var(--color-neutral-300);
border-radius: var(--radius-pill);
- font-size: 13px;
- font-weight: 600;
- padding: 5px 12px;
+ background: var(--color-neutral-200);
+ color: var(--color-black-300);
+ font-size: 14px;
+ padding: 5px 14px;
}
.status-pill:empty {
display: none;
}
-.status-pill[data-state="live"],
-.status-pill[data-state="error"] {
- background: var(--color-danger-soft);
- color: var(--color-danger);
+.status-pill[data-state="live"] {
+ border-color: var(--color-cobolt-200);
+ background: var(--color-cobolt-100);
+ color: var(--color-cobolt-500);
}
.status-pill[data-state="idle"] {
- background: var(--color-neutral-soft);
- color: var(--color-neutral);
+ border-color: var(--color-neutral-300);
+ background: var(--color-neutral-200);
+ color: var(--color-black-300);
+}
+
+.status-pill[data-state="error"] {
+ border-color: var(--color-blush-500);
+ background: var(--color-blush-200);
+ color: var(--color-black-500);
}
/* CAPTION VIEW */
.caption-output {
- min-height: 120px;
+ min-height: 140px;
margin-top: 24px;
border: 1px solid var(--color-border);
border-radius: var(--radius-panel);
background: var(--color-surface);
- box-shadow: var(--shadow-panel);
- font-size: 19px;
- line-height: 1.7;
- padding: 18px;
+ font-size: 28px;
+ line-height: 1.45;
+ letter-spacing: -0.2px;
+ padding: 24px;
}
.caption-final {
- color: var(--color-text);
+ color: var(--color-text-dark);
}
.caption-partial {
color: var(--color-text-muted);
}
-@media (max-width: 640px) {
+@media (max-width: 768px) {
:root {
- --space-page-block: 32px;
+ --space-page-block: 40px;
+ }
+
+ .page-title {
+ font-size: 30px;
+ letter-spacing: -1.5px;
}
.button {
diff --git a/aai_cli/init/templates/voice-agent/static/index.html b/aai_cli/init/templates/voice-agent/static/index.html
index 8c5f7e6b..311794f6 100644
--- a/aai_cli/init/templates/voice-agent/static/index.html
+++ b/aai_cli/init/templates/voice-agent/static/index.html
@@ -8,9 +8,14 @@
+
+
+
+
@@ -19,6 +24,11 @@
Talk to a voice agent
+
+
diff --git a/aai_cli/init/templates/voice-agent/static/styles.css b/aai_cli/init/templates/voice-agent/static/styles.css
index c3223409..2ea6cc6f 100644
--- a/aai_cli/init/templates/voice-agent/static/styles.css
+++ b/aai_cli/init/templates/voice-agent/static/styles.css
@@ -1,37 +1,82 @@
-/* THEME TOKENS: edit this block first for monotone colors, spacing, radii, and width. */
+/* THEME TOKENS — AssemblyAI design system. Edit this block first to retheme.
+ Brand fonts hotlink from assemblyai.com (CORS-open) with system fallbacks. */
+@font-face {
+ font-family: "Oceanic Text";
+ src: url("https://www.assemblyai.com/_aai/fonts/rebrand/OceanicText-Regular.otf")
+ format("opentype");
+ font-weight: 400;
+ font-display: swap;
+}
+@font-face {
+ font-family: "UN 11ST";
+ src: url("https://www.assemblyai.com/_aai/fonts/rebrand/UN_11ST_Regular.woff2")
+ format("woff2");
+ font-weight: 400;
+ font-display: swap;
+}
+@font-face {
+ font-family: "UN 11ST";
+ src: url("https://www.assemblyai.com/_aai/fonts/rebrand/UN_11ST_Bold.woff2")
+ format("woff2");
+ font-weight: 700;
+ font-display: swap;
+}
+@font-face {
+ font-family: "Modern Gothic Mono";
+ src: url("https://www.assemblyai.com/_aai/fonts/rebrand/ModernGothicMono-Regular.woff2")
+ format("woff2");
+ font-weight: 400;
+ font-display: swap;
+}
+
:root {
- --font-sans:
- -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
- --background: oklch(0.985 0 0);
- --foreground: oklch(0.12 0 0);
- --card: oklch(1 0 0);
- --primary: oklch(0.12 0 0);
- --primary-foreground: oklch(0.985 0 0);
- --secondary: oklch(0.965 0 0);
- --secondary-foreground: oklch(0.38 0 0);
- --muted-foreground: oklch(0.58 0 0);
- --border: oklch(0.9 0 0);
- --shadow-focus:
- 0 0 0 1px oklch(0 0 0 / 0.08), 0 2px 8px -2px oklch(0 0 0 / 0.08);
- --color-bg: var(--background);
- --color-surface: var(--card);
- --color-text: var(--foreground);
- --color-text-muted: var(--muted-foreground);
- --color-border: var(--border);
- --color-accent: var(--primary);
- --color-accent-contrast: var(--primary-foreground);
- --color-user: oklch(0.42 0 0);
- --color-success: var(--foreground);
- --color-success-soft: var(--secondary);
- --color-danger: var(--foreground);
- --color-danger-strong: var(--foreground);
- --color-danger-soft: var(--secondary);
- --color-neutral: var(--secondary-foreground);
- --color-neutral-soft: var(--secondary);
+ /* Brand fonts */
+ --font-display: "Oceanic Text", Georgia, serif;
+ --font-body: "UN 11ST", system-ui, sans-serif;
+ --font-mono: "Modern Gothic Mono", "JetBrains Mono", monospace;
+
+ /* Cobolt (brand purple — the only accent) */
+ --color-cobolt-500: #3923c7;
+ --color-cobolt-300: #887bdd;
+ --color-cobolt-200: #b0a7e9;
+ --color-cobolt-100: #d7d3f4;
+
+ /* Warm neutrals */
+ --color-black-500: #1d1b16;
+ --color-black-400: #4a4945;
+ --color-black-300: #777673;
+ --color-white-100: #ffffff;
+ --color-white-200: #fdfcf8;
+ --color-white-300: #f5f3eb;
+ --color-neutral-100: #ecebe5;
+ --color-neutral-200: #dad7cb;
+ --color-neutral-300: #c7c3b2;
+
+ /* Blush (error highlight only) */
+ --color-blush-200: #f4d4d0;
+ --color-blush-500: #e39389;
+
+ /* Semantic aliases — downstream rules use these */
+ --color-bg: var(--color-white-200);
+ --color-surface: var(--color-white-100);
+ --color-text: var(--color-black-400);
+ --color-text-dark: var(--color-black-500);
+ --color-text-muted: var(--color-black-300);
+ --color-border: var(--color-neutral-300);
+ --color-accent: var(--color-cobolt-500);
+ --color-accent-hover: var(--color-cobolt-300);
+ --color-accent-contrast: var(--color-white-100);
+ --color-user: var(--color-black-400);
+ --color-connected: var(--color-black-500);
+
+ --shadow-focus: 0 0 0 3px var(--color-cobolt-100);
+
+ --radius-cta: 4px;
--radius-control: 8px;
- --radius-pill: 999px;
- --space-page-block: 48px;
- --space-page-inline: 16px;
+ --radius-panel: 12px;
+ --radius-pill: 9999px;
+ --space-page-block: 64px;
+ --space-page-inline: 24px;
--content-width: 720px;
}
@@ -46,12 +91,24 @@ body {
padding: var(--space-page-block) var(--space-page-inline);
background: var(--color-bg);
color: var(--color-text);
- font: 15px/1.6 var(--font-sans);
+ font: 16px/1.3 var(--font-body);
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+ font-family: var(--font-display);
+ font-weight: 400;
+ line-height: 1;
+ color: var(--color-text-dark);
+}
+
button {
appearance: none;
font: inherit;
@@ -63,22 +120,78 @@ button {
margin: 0 auto;
}
+/* BRAND CHROME */
+.brand {
+ display: inline-flex;
+ align-items: center;
+ margin-bottom: 40px;
+}
+
+.brand-logo {
+ display: block;
+ width: auto;
+ height: 20px;
+}
+
+.eyebrow {
+ display: inline-flex;
+ align-items: center;
+ margin-bottom: 16px;
+ padding: 8px 16px;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-cta);
+ color: var(--color-black-400);
+ font-family: var(--font-mono);
+ font-size: 12px;
+ letter-spacing: 1.2px;
+ text-transform: uppercase;
+ font-feature-settings: "ss09" 1;
+}
+
.page-header {
- margin-bottom: 24px;
+ margin-bottom: 32px;
}
.page-title {
- margin: 0 0 4px;
- font-size: 22px;
- line-height: 1.25;
+ margin: 0 0 12px;
+ font-size: 48px;
+ letter-spacing: -2.4px;
}
.page-subtitle {
- max-width: 68ch;
+ max-width: 60ch;
margin: 0;
+ font-size: 18px;
color: var(--color-text-muted);
}
+.page-footer {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-top: 64px;
+ padding-top: 24px;
+ border-top: 1px solid var(--color-border);
+ color: var(--color-text-muted);
+ font-size: 14px;
+}
+
+.footer-link {
+ color: var(--color-accent);
+ font-family: var(--font-mono);
+ font-size: 12px;
+ letter-spacing: 1.2px;
+ text-transform: uppercase;
+ text-decoration: none;
+ transition: color 0.2s ease;
+}
+
+.footer-link:hover {
+ color: var(--color-accent-hover);
+}
+
.control-bar {
display: flex;
align-items: center;
@@ -91,22 +204,25 @@ button {
--button-bg: var(--color-accent);
--button-fg: var(--color-accent-contrast);
display: inline-flex;
- min-height: 44px;
+ min-height: 40px;
align-items: center;
justify-content: center;
border: 0;
- border-radius: var(--radius-control);
+ border-radius: var(--radius-cta);
background: var(--button-bg);
color: var(--button-fg);
cursor: pointer;
+ font-family: var(--font-mono);
font-size: 14px;
- font-weight: 600;
- padding: 0 20px;
+ letter-spacing: 1.4px;
+ text-transform: uppercase;
+ padding: 0 18px;
white-space: nowrap;
+ transition: background-color 0.2s ease;
}
.button:hover:not(:disabled) {
- filter: brightness(0.96);
+ --button-bg: var(--color-accent-hover);
}
.button:focus-visible {
@@ -114,17 +230,29 @@ button {
outline: none;
}
+.button:disabled {
+ cursor: default;
+ opacity: 0.55;
+}
+
.connection-button[data-state="connected"] {
- --button-bg: var(--color-danger-strong);
+ --button-bg: var(--color-connected);
+}
+
+.connection-button[data-state="connected"]:hover:not(:disabled) {
+ --button-bg: var(--color-black-400);
}
/* STATUS STATES */
.status-pill {
- display: inline-block;
+ display: inline-flex;
+ align-items: center;
+ border: 1px solid var(--color-neutral-300);
border-radius: var(--radius-pill);
- font-size: 13px;
- font-weight: 600;
- padding: 5px 12px;
+ background: var(--color-neutral-200);
+ color: var(--color-black-300);
+ font-size: 14px;
+ padding: 5px 14px;
}
.status-pill:empty {
@@ -132,18 +260,21 @@ button {
}
.status-pill[data-state="live"] {
- background: var(--color-success-soft);
- color: var(--color-success);
+ border-color: var(--color-cobolt-200);
+ background: var(--color-cobolt-100);
+ color: var(--color-cobolt-500);
}
.status-pill[data-state="idle"] {
- background: var(--color-neutral-soft);
- color: var(--color-neutral);
+ border-color: var(--color-neutral-300);
+ background: var(--color-neutral-200);
+ color: var(--color-black-300);
}
.status-pill[data-state="error"] {
- background: var(--color-danger-soft);
- color: var(--color-danger);
+ border-color: var(--color-blush-500);
+ background: var(--color-blush-200);
+ color: var(--color-black-500);
}
/* CONVERSATION VIEW */
@@ -167,7 +298,10 @@ button {
}
.turn-speaker {
- font-weight: 700;
+ font-family: var(--font-mono);
+ font-size: 12px;
+ letter-spacing: 1.2px;
+ text-transform: uppercase;
}
.conversation-turn[data-speaker="you"] .turn-speaker {
@@ -178,9 +312,14 @@ button {
color: var(--color-accent);
}
-@media (max-width: 640px) {
+@media (max-width: 768px) {
:root {
- --space-page-block: 32px;
+ --space-page-block: 40px;
+ }
+
+ .page-title {
+ font-size: 30px;
+ letter-spacing: -1.5px;
}
.button,
diff --git a/aai_cli/main.py b/aai_cli/main.py
index dab589b8..de4d7537 100644
--- a/aai_cli/main.py
+++ b/aai_cli/main.py
@@ -95,11 +95,21 @@ def parse_args(self, ctx: ClickContext, args: list[str]) -> list[str]:
return super().parse_args(ctx, args)
-# Typer renders option flags and command names in "bold cyan" by default; retint
-# both to the brand accent (the logo blue) so the help screen matches the rest of
-# the CLI. Set before the app renders any help.
+# Typer's default help palette is a rainbow: option flags/command names in "bold cyan",
+# the short switch (e.g. -p) in "bold green", and the type metavar (e.g. TEXT) in "bold
+# yellow". Retint the whole panel into the Cobolt brand family so help reads as one
+# monochrome hierarchy: flags and command names in the bold primary accent, their short
+# aliases matching, and the type metavar in the lighter secondary Cobolt so it recedes.
+# Set before the app renders any help.
rich_utils.STYLE_OPTION = f"bold {theme.BRAND}"
rich_utils.STYLE_COMMANDS_TABLE_FIRST_COLUMN = f"bold {theme.BRAND}"
+rich_utils.STYLE_SWITCH = f"bold {theme.BRAND}"
+rich_utils.STYLE_METAVAR = theme.ACCENT
+# The usage line ("Usage: aai [OPTIONS] COMMAND [ARGS]...") defaults to yellow. Keep the
+# program name in the bold brand accent so it matches command names elsewhere, but drop
+# the "Usage:" label and arg spec to muted warm gray — it's boilerplate that should recede.
+rich_utils.STYLE_USAGE = theme.MUTED
+rich_utils.STYLE_USAGE_COMMAND = f"bold {theme.BRAND}"
# Typer's built-in `--show-completion` help is long enough to wrap several lines in
# the options panel. Trim it so it fits on fewer rows. The OptionInfo objects live on
diff --git a/aai_cli/theme.py b/aai_cli/theme.py
index 68f0af45..00e223fe 100644
--- a/aai_cli/theme.py
+++ b/aai_cli/theme.py
@@ -6,11 +6,29 @@
from rich.text import Text
from rich.theme import Theme
-# AssemblyAI brand accent. Defined once so the whole CLI can be re-tinted here.
-BRAND = "#2545D3"
-# Secondary accent — a darker blue used where a second hue is needed (agent label,
-# links), kept in the blue family rather than the old cyan.
-DARK_BLUE = "#1E3A8A"
+# Cobolt — the AssemblyAI brand purple, taken from the marketing design system.
+# The website's primary CTA is Cobolt 500; its dark-mode primary is Cobolt 300.
+# A terminal's background is unknown (dark or light), so the CLI uses the mid step,
+# Cobolt 400, as its accent: bright enough to read on black, saturated enough to read
+# on white, and unambiguously the brand purple either way.
+COBOLT_500 = "#3923c7" # web primary CTA / brand purple
+COBOLT_400 = "#614fd2" # CLI primary accent (terminal-legible mid step)
+COBOLT_300 = "#887bdd" # web dark-mode primary / hover; CLI secondary accent
+
+# Brand accent. Defined once so the whole CLI can be re-tinted here.
+BRAND = COBOLT_400
+# Secondary accent — the lighter Cobolt 300 used where a second hue is needed (agent
+# label, links), so it stays in the brand family yet reads distinct from BRAND.
+ACCENT = COBOLT_300
+
+# Semantic error red, matching the design system's --color-error. Rich downsamples it
+# to plain red on terminals without truecolor.
+ERROR = "#F04438"
+
+# Muted warm gray, matching the design system's --color-text-muted (Black 300). A true
+# mid-gray that reads on both dark and light terminals; used for quiet labels (e.g. the
+# help "Usage:" header) that should recede rather than carry the brand accent.
+MUTED = "#777673"
# A fixed affordance vocabulary used across all human-facing output, mirroring the
# Vercel/Supabase convention of a small, consistent status alphabet: one glyph per
@@ -22,7 +40,7 @@
SYMBOL_HINT = "›" # noqa: RUF001 — deliberate angle-quote glyph, not a '>' typo
# Per-speaker label colors, rotated deterministically by speaker_style(). Deliberately
-# excludes the brand blue: that hue is reserved for "you" (aai.you) so a diarized system
+# excludes the brand purple: that hue is reserved for "you" (aai.you) so a diarized system
# speaker can never be tinted the same color as your own mic.
SPEAKER_STYLES: tuple[str, ...] = (
"aai.speaker.0",
@@ -41,15 +59,15 @@
# for a diarized speaker, see SPEAKER_STYLES), the agent gets a distinct hue so
# "you:" and "agent:" are easy to tell apart at a glance.
"aai.you": BRAND,
- "aai.agent": DARK_BLUE,
- # Links/URLs in the dark-blue secondary accent so a clickable target stands out
- # from prose without shouting (Vercel/Supabase use a cool accent for the same).
- "aai.url": DARK_BLUE,
+ "aai.agent": ACCENT,
+ # Links/URLs in the lighter Cobolt secondary accent so a clickable target stands
+ # out from prose without shouting (Vercel/Supabase use a cool accent for the same).
+ "aai.url": ACCENT,
# Semantic status colors. Success is bold so the ✓ reads as a confident "done"
- # (Supabase-style); error/warn follow the universal red/yellow; muted secondary
- # text stays dim so it recedes (the Vercel "quiet by default" look).
+ # (Supabase-style); error uses the brand's semantic red, warn the universal yellow;
+ # muted secondary text stays dim so it recedes (the Vercel "quiet by default" look).
"aai.success": "bold green",
- "aai.error": "bold red",
+ "aai.error": f"bold {ERROR}",
"aai.warn": "yellow",
"aai.muted": "dim",
"aai.speaker.0": "dark_orange",
diff --git a/pyproject.toml b/pyproject.toml
index db627800..2da7eef3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -67,6 +67,14 @@ dev = [
"pytest-mock>=3.14.0",
"pytest-randomly>=3.16.0",
"pytest-xdist>=3.6.0",
+ # Hermeticity guards beyond order-randomization (pytest-randomly):
+ # pytest-socket blocks real network in the unit suite so an unmocked SDK/HTTP
+ # call fails loudly instead of silently hitting the API (see addopts below);
+ # time-machine lets tests freeze the clock, complementing the TZ pin in
+ # tests/conftest.py so time rendering is deterministic across machines.
+ # Floors sit at the second-newest release so safe-chain's min-age gate can resolve.
+ "pytest-socket>=0.7.0",
+ "time-machine>=3.1.0",
"hypothesis>=6.155.1",
"ruff>=0.15.15",
"mypy>=2.1.0",
@@ -106,6 +114,14 @@ exclude = ["**/__pycache__", "**/*.pyc"]
[tool.pytest.ini_options]
testpaths = ["tests"]
+# Block real network in every pytest invocation (pytest-socket). The unit suite is
+# fully mocked, so an accidental real SDK/HTTP call now raises SocketBlockedError
+# instead of silently reaching the API — hard-enforcing the unit/e2e split we keep
+# by convention. --allow-unix-socket keeps AF_UNIX (used by some libs) working. Tests
+# that bind a loopback server (OAuth callback, dev server, tunnel) opt back in with
+# `@pytest.mark.allow_hosts([...])`. The e2e/install suites drive the CLI as a
+# subprocess, whose sockets this in-process patch never sees, so they're unaffected.
+addopts = "--disable-socket --allow-unix-socket"
# Treat warnings as errors so a deprecation or resource leak introduced in a change
# fails the build instead of scrolling past. Listed-later filters take precedence, so
# the targeted ignores below override the blanket `error`.
diff --git a/tests/conftest.py b/tests/conftest.py
index 7cfce098..05d23408 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,4 +1,5 @@
import os
+import time
import keyring
import pytest
@@ -9,6 +10,19 @@
# unit tests still run fully isolated.
REAL_API_KEY = os.environ.get("ASSEMBLYAI_API_KEY")
+# Suites that legitimately reach the real network in-process (PyPI reachability
+# probes before a real install, real-API e2e) are gated behind these markers. They
+# opt out of the suite-wide --disable-socket; every other test stays blocked, so an
+# unmocked call in the unit suite still fails loudly. Tests that only bind a loopback
+# server use the tighter `@pytest.mark.allow_hosts(["127.0.0.1"])` instead.
+_NETWORK_MARKERS = ("e2e", "install", "install_script")
+
+
+def pytest_collection_modifyitems(items: list[pytest.Item]) -> None:
+ for item in items:
+ if any(item.get_closest_marker(name) for name in _NETWORK_MARKERS):
+ item.add_marker(pytest.mark.enable_socket)
+
@pytest.fixture
def real_api_key():
@@ -54,6 +68,19 @@ def isolate_env(monkeypatch):
monkeypatch.delenv(var, raising=False)
+@pytest.fixture(autouse=True)
+def pin_timezone(monkeypatch):
+ # Pin the host timezone so any time rendering is deterministic across machines and
+ # CI, regardless of the contributor's local zone. A fixed *non-UTC* zone is
+ # deliberate: the CLI normalizes everything to UTC (timeparse.format_utc_*), so
+ # correct code is unaffected, while a future naive/local clock would visibly shift
+ # here instead of only on a laptop that happens to sit in that zone. Tests that
+ # need a frozen "now" use time-machine on top of this.
+ monkeypatch.setenv("TZ", "America/New_York")
+ if hasattr(time, "tzset"):
+ time.tzset()
+
+
@pytest.fixture(autouse=True)
def reset_active_environment():
# The active environment is a process-global (set at CLI startup); pin it to
diff --git a/tests/test_auth_loopback.py b/tests/test_auth_loopback.py
index c587fdbf..8290d1c0 100644
--- a/tests/test_auth_loopback.py
+++ b/tests/test_auth_loopback.py
@@ -8,6 +8,11 @@
from aai_cli.auth import endpoints, loopback
from aai_cli.errors import APIError
+# These tests bind a real loopback HTTP server and connect to it, so they opt back
+# into sockets past the suite-wide --disable-socket (see pyproject pytest config).
+# Restricting to 127.0.0.1 keeps the external-network block intact.
+pytestmark = pytest.mark.allow_hosts(["127.0.0.1"])
+
@pytest.fixture(autouse=True)
def _unique_loopback_port(monkeypatch):
diff --git a/tests/test_init_runner.py b/tests/test_init_runner.py
index 04e157f4..903f49a1 100644
--- a/tests/test_init_runner.py
+++ b/tests/test_init_runner.py
@@ -60,11 +60,17 @@ def test_serve_command_uv_and_venv():
]
+# These two bind a real loopback socket, so they opt back into sockets past the
+# suite-wide --disable-socket (see pyproject pytest config); 127.0.0.1 only, so the
+# external-network block stays in force. test_find_free_port_raises_when_all_taken
+# monkeypatches the probe and needs no socket.
+@pytest.mark.allow_hosts(["127.0.0.1"])
def test_find_free_port_returns_preferred_when_open():
port = runner.find_free_port(0) # 0 -> OS assigns a free port
assert isinstance(port, int) and port > 0
+@pytest.mark.allow_hosts(["127.0.0.1"])
def test_find_free_port_skips_taken_port():
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(("127.0.0.1", 0))
diff --git a/tests/test_setup_render.py b/tests/test_setup_render.py
index 9c4df89f..8efd4bb2 100644
--- a/tests/test_setup_render.py
+++ b/tests/test_setup_render.py
@@ -19,10 +19,14 @@ def test_render_steps_colors_status() -> None:
assert "[aai.heading]" in rendered
# ...and renders to real ANSI through the themed console.
buf = io.StringIO()
- console = theme.make_console(file=buf, force_terminal=True, color_system="truecolor")
+ # _environ={} pins the color depth: Rich reads ambient color env at render time,
+ # which leaks across tests and otherwise flips truecolor to a 16-color downsample.
+ console = theme.make_console(
+ file=buf, force_terminal=True, color_system="truecolor", _environ={}
+ )
console.print(rendered)
out = buf.getvalue()
assert "installed" in out
assert "failed" in out
assert "\x1b[1;32m" in out # aai.success (bold green) → "installed"
- assert "\x1b[1;31m" in out # aai.error (bold red) → "failed"
+ assert "\x1b[1;38;2;240;68;56m" in out # aai.error (bold brand red #F04438) → "failed"
diff --git a/tests/test_transcripts.py b/tests/test_transcripts.py
index e53ab24c..ebfaaf13 100644
--- a/tests/test_transcripts.py
+++ b/tests/test_transcripts.py
@@ -134,10 +134,13 @@ def test_list_table_colors_status(monkeypatch, mocker):
config.set_api_key("default", "sk_live")
monkeypatch.setattr("aai_cli.output.resolve_json", lambda *, explicit: False)
- # Force a real color terminal so styling produces ANSI we can assert on.
+ # Pin a truecolor console with an empty _environ so the rendered ANSI is
+ # deterministic: Rich otherwise reads ambient color env (NO_COLOR/COLORTERM/...)
+ # at render time, which leaks across tests and flips the color depth. With
+ # _environ={} the depth is fixed by color_system alone.
monkeypatch.setattr(
"aai_cli.output.console",
- make_console(force_terminal=True, color_system="truecolor"),
+ make_console(force_terminal=True, color_system="truecolor", _environ={}),
)
rows = [
{"id": "t1", "status": "completed", "created": "2026-01-01"},
@@ -151,4 +154,4 @@ def test_list_table_colors_status(monkeypatch, mocker):
assert "completed" in result.output
assert "error" in result.output
assert "\x1b[1;32m" in result.output # aai.success (bold green) → "completed" cell
- assert "\x1b[1;31m" in result.output # aai.error (bold red) → "error" cell
+ assert "\x1b[1;38;2;240;68;56m" in result.output # aai.error (bold #F04438) → "error" cell
diff --git a/uv.lock b/uv.lock
index 342b3638..e9bafbcd 100644
--- a/uv.lock
+++ b/uv.lock
@@ -45,11 +45,13 @@ dev = [
{ name = "pytest-cov" },
{ name = "pytest-mock" },
{ name = "pytest-randomly" },
+ { name = "pytest-socket" },
{ name = "pytest-xdist" },
{ name = "python-dotenv" },
{ name = "python-multipart" },
{ name = "ruff" },
{ name = "syrupy" },
+ { name = "time-machine" },
{ name = "uvicorn" },
{ name = "vulture" },
{ name = "xenon" },
@@ -90,11 +92,13 @@ dev = [
{ name = "pytest-cov", specifier = ">=7.1.0" },
{ name = "pytest-mock", specifier = ">=3.14.0" },
{ name = "pytest-randomly", specifier = ">=3.16.0" },
+ { name = "pytest-socket", specifier = ">=0.7.0" },
{ name = "pytest-xdist", specifier = ">=3.6.0" },
{ name = "python-dotenv", specifier = ">=1.0.0" },
{ name = "python-multipart", specifier = ">=0.0.9" },
{ name = "ruff", specifier = ">=0.15.15" },
{ name = "syrupy", specifier = ">=4.0" },
+ { name = "time-machine", specifier = ">=3.1.0" },
{ name = "uvicorn", specifier = ">=0.30.0" },
{ name = "vulture", specifier = ">=2.14" },
{ name = "xenon", specifier = ">=0.9.3" },
@@ -1492,6 +1496,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/db/2df9a1fca597a273f957a559c20c2d95d629928384507b2afa43ba6909d1/pytest_randomly-4.1.0-py3-none-any.whl", hash = "sha256:f55e89e53367b090c0c053697d7f9d77595543d0e0516c93978b50c0f6b252f9", size = 8353, upload-time = "2026-04-20T13:01:50.382Z" },
]
+[[package]]
+name = "pytest-socket"
+version = "0.8.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/59/3c/f9b58e57830e58980dbe8867d0e348f45701d3f3ea065672d448f4366da5/pytest_socket-0.8.0.tar.gz", hash = "sha256:af9bb5f487da72be63573a6194cfac033b6c7a1c1561e150521105970f9e99f2", size = 13912, upload-time = "2026-05-21T16:50:22.552Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3f/e8/4a8568580bae3dcd678599ed8e86a82d505a44df71c1ced4246c1aa14b4b/pytest_socket-0.8.0-py3-none-any.whl", hash = "sha256:81821ba59f07d7600fe2b551d8714f40b068bd46e8b6704c48664e9d60cdacb8", size = 8414, upload-time = "2026-05-21T16:50:21.022Z" },
+]
+
[[package]]
name = "pytest-xdist"
version = "3.8.0"
@@ -1771,6 +1787,69 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/18/dc99a152bea18a898a8ac387bfeb9ec0829e0f5bed11cfec2e2ca189c5a2/syrupy-5.2.0-py3-none-any.whl", hash = "sha256:798cb493a6e20f4839e58ea8f10eb1b0d85684c676442f79786e219bf32618e6", size = 51828, upload-time = "2026-05-16T21:11:34.984Z" },
]
+[[package]]
+name = "time-machine"
+version = "3.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/02/fc/37b02f6094dbb1f851145330460532176ed2f1dc70511a35828166c41e52/time_machine-3.2.0.tar.gz", hash = "sha256:a4ddd1cea17b8950e462d1805a42b20c81eb9aafc8f66b392dd5ce997e037d79", size = 14804, upload-time = "2025-12-17T23:33:02.599Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/71/8b/080c8eedcd67921a52ba5bd0e075362062509ab63c86fc1a0442fad241a6/time_machine-3.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cc4bee5b0214d7dc4ebc91f4a4c600f1a598e9b5606ac751f42cb6f6740b1dbb", size = 19255, upload-time = "2025-12-17T23:31:58.057Z" },
+ { url = "https://files.pythonhosted.org/packages/66/17/0e5291e9eb705bf8a5a1305f826e979af307bbeb79def4ddbf4b3f9a81e0/time_machine-3.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3ca036304b4460ae2fdc1b52dd8b1fa7cf1464daa427fc49567413c09aa839c1", size = 15360, upload-time = "2025-12-17T23:31:59.048Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/e8/9ab87b71d2e2b62463b9b058b7ae7ac09fb57f8fcd88729dec169d304340/time_machine-3.2.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5442735b41d7a2abc2f04579b4ca6047ed4698a8338a4fec92c7c9423e7938cb", size = 33029, upload-time = "2025-12-17T23:32:00.413Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/26/b5ca19da6f25ea905b3e10a0ea95d697c1aeba0404803a43c68f1af253e6/time_machine-3.2.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:97da3e971e505cb637079fb07ab0bcd36e33279f8ecac888ff131f45ef1e4d8d", size = 34579, upload-time = "2025-12-17T23:32:01.431Z" },
+ { url = "https://files.pythonhosted.org/packages/79/ca/6ac7ad5f10ea18cc1d9de49716ba38c32132c7b64532430d92ef240c116b/time_machine-3.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3cdda6dee4966e38aeb487309bb414c6cb23a81fc500291c77a8fcd3098832e7", size = 35961, upload-time = "2025-12-17T23:32:02.521Z" },
+ { url = "https://files.pythonhosted.org/packages/33/67/390dd958bed395ab32d79a9fe61fe111825c0dd4ded54dbba7e867f171e6/time_machine-3.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:33d9efd302a6998bcc8baa4d84f259f8a4081105bd3d7f7af7f1d0abd3b1c8aa", size = 34668, upload-time = "2025-12-17T23:32:03.585Z" },
+ { url = "https://files.pythonhosted.org/packages/da/57/c88fff034a4e9538b3ae7c68c9cfb283670b14d17522c5a8bc17d29f9a4b/time_machine-3.2.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3a0b0a33971f14145853c9bd95a6ab0353cf7e0019fa2a7aa1ae9fddfe8eab50", size = 32891, upload-time = "2025-12-17T23:32:04.656Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/70/ebbb76022dba0fec8f9156540fc647e4beae1680c787c01b1b6200e56d70/time_machine-3.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2d0be9e5f22c38082d247a2cdcd8a936504e9db60b7b3606855fb39f299e9548", size = 34080, upload-time = "2025-12-17T23:32:06.146Z" },
+ { url = "https://files.pythonhosted.org/packages/db/9a/2ca9e7af3df540dc1c79e3de588adeddb7dcc2107829248e6969c4f14167/time_machine-3.2.0-cp312-cp312-win32.whl", hash = "sha256:3f74623648b936fdce5f911caf386c0a0b579456410975de8c0dfeaaffece1d8", size = 17371, upload-time = "2025-12-17T23:32:07.164Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/ce/21d23efc9c2151939af1b7ee4e60d86d661b74ef32b8eaa148f6fe8c899c/time_machine-3.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:34e26a41d994b5e4b205136a90e9578470386749cc9a2ecf51ca18f83ce25e23", size = 18132, upload-time = "2025-12-17T23:32:08.447Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/34/c2b70be483accf6db9e5d6c3139bce3c38fe51f898ccf64e8d3fe14fbf4d/time_machine-3.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:0615d3d82c418d6293f271c348945c5091a71f37e37173653d5c26d0e74b13a8", size = 16930, upload-time = "2025-12-17T23:32:09.477Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/cd/43ad5efc88298af3c59b66769cea7f055567a85071579ed40536188530c1/time_machine-3.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c421a8eb85a4418a7675a41bf8660224318c46cc62e4751c8f1ceca752059090", size = 19318, upload-time = "2025-12-17T23:32:10.518Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/f6/084010ef7f4a3f38b5a4900923d7c85b29e797655c4f6ee4ce54d903cca8/time_machine-3.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f4e758f7727d0058c4950c66b58200c187072122d6f7a98b610530a4233ea7b", size = 15390, upload-time = "2025-12-17T23:32:11.625Z" },
+ { url = "https://files.pythonhosted.org/packages/25/aa/1cabb74134f492270dc6860cb7865859bf40ecf828be65972827646e91ad/time_machine-3.2.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:154bd3f75c81f70218b2585cc12b60762fb2665c507eec5ec5037d8756d9b4e0", size = 33115, upload-time = "2025-12-17T23:32:13.219Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/03/78c5d7dfa366924eb4dbfcc3fc917c39a4280ca234b12819cc1f16c03d88/time_machine-3.2.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d50cfe5ebea422c896ad8d278af9648412b7533b8ea6adeeee698a3fd9b1d3b7", size = 34705, upload-time = "2025-12-17T23:32:14.29Z" },
+ { url = "https://files.pythonhosted.org/packages/86/93/d5e877c24541f674c6869ff6e9c56833369796010190252e92c9d7ae5f0f/time_machine-3.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:636576501724bd6a9124e69d86e5aef263479e89ef739c5db361469f0463a0a1", size = 36104, upload-time = "2025-12-17T23:32:15.354Z" },
+ { url = "https://files.pythonhosted.org/packages/22/1c/d4bae72f388f67efc9609f89b012e434bb19d9549c7a7b47d6c7d9e5c55d/time_machine-3.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:40e6f40c57197fcf7ec32d2c563f4df0a82c42cdcc3cab27f688e98f6060df10", size = 34765, upload-time = "2025-12-17T23:32:16.434Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/c3/ac378cf301d527d8dfad2f0db6bad0dfb1ab73212eaa56d6b96ee5d9d20b/time_machine-3.2.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a1bcf0b846bbfc19a79bc19e3fa04d8c7b1e8101c1b70340ffdb689cd801ea53", size = 33010, upload-time = "2025-12-17T23:32:17.532Z" },
+ { url = "https://files.pythonhosted.org/packages/06/35/7ce897319accda7a6970b288a9a8c52d25227342a7508505a2b3d235b649/time_machine-3.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ae55a56c179f4fe7a62575ad5148b6ed82f6c7e5cf2f9a9ec65f2f5b067db5f5", size = 34185, upload-time = "2025-12-17T23:32:18.566Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/28/f922022269749cb02eee2b62919671153c4088994fa955a6b0e50327ff81/time_machine-3.2.0-cp313-cp313-win32.whl", hash = "sha256:a66fe55a107e46916007a391d4030479df8864ec6ad6f6a6528221befc5c886e", size = 17397, upload-time = "2025-12-17T23:32:19.605Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/dc/fd87cde397f4a7bea493152f0aca8fd569ec709cad9e0f2ca7011eb8c7f7/time_machine-3.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:30c9ce57165df913e4f74e285a8ab829ff9b7aa3e5ec0973f88f642b9a7b3d15", size = 18139, upload-time = "2025-12-17T23:32:20.991Z" },
+ { url = "https://files.pythonhosted.org/packages/75/81/b8ce58233addc5d7d54d2fabc49dcbc02d79e3f079d150aa1bec3d5275ef/time_machine-3.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:89cad7e179e9bdcc84dcf09efe52af232c4cc7a01b3de868356bbd59d95bd9b8", size = 16964, upload-time = "2025-12-17T23:32:22.075Z" },
+ { url = "https://files.pythonhosted.org/packages/67/e7/487f0ba5fe6c58186a5e1af2a118dfa2c160fedb37ef53a7e972d410408e/time_machine-3.2.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:59d71545e62525a4b85b6de9ab5c02ee3c61110fd7f636139914a2335dcbfc9c", size = 20000, upload-time = "2025-12-17T23:32:23.058Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/17/eb2c0054c8d44dd42df84ccd434539249a9c7d0b8eb53f799be2102500ab/time_machine-3.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:999672c621c35362bc28e03ca0c7df21500195540773c25993421fd8d6cc5003", size = 15657, upload-time = "2025-12-17T23:32:24.125Z" },
+ { url = "https://files.pythonhosted.org/packages/43/21/93443b5d1dd850f8bb9442e90d817a9033dcce6bfbdd3aabbb9786251c80/time_machine-3.2.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5faf7397f0580c7b9d67288522c8d7863e85f0cffadc0f1fccdb2c3dfce5783e", size = 39216, upload-time = "2025-12-17T23:32:25.542Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/9e/18544cf8acc72bb1dc03762231c82ecc259733f4bb6770a7bbe5cd138603/time_machine-3.2.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3dd886ec49f1fa5a00e844f5947e5c0f98ce574750c24b7424c6f77fc1c3e87", size = 40764, upload-time = "2025-12-17T23:32:26.643Z" },
+ { url = "https://files.pythonhosted.org/packages/27/f7/9fe9ce2795636a3a7467307af6bdf38bb613ddb701a8a5cd50ec713beb5e/time_machine-3.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da0ecd96bc7bbe450acaaabe569d84e81688f1be8ad58d1470e42371d145fb53", size = 43526, upload-time = "2025-12-17T23:32:27.693Z" },
+ { url = "https://files.pythonhosted.org/packages/03/c1/a93e975ba9dec22e87ec92d18c28e67d36bd536f9119ffa439b2892b0c9c/time_machine-3.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:158220e946c1c4fb8265773a0282c88c35a7e3bb5d78e3561214e3b3231166f3", size = 41727, upload-time = "2025-12-17T23:32:28.985Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/fb/e3633e5a6bbed1c76bb2e9810dabc2f8467532ffcd29b9aed404b473061a/time_machine-3.2.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8c1aee29bc54356f248d5d7dfdd131e12ca825e850a08c0ebdb022266d073013", size = 38952, upload-time = "2025-12-17T23:32:30.031Z" },
+ { url = "https://files.pythonhosted.org/packages/82/3d/02e9fb2526b3d6b1b45bc8e4d912d95d1cd699d1a3f6df985817d37a0600/time_machine-3.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c8ed2224f09d25b1c2fc98683613aca12f90f682a427eabb68fc824d27014e4a", size = 39829, upload-time = "2025-12-17T23:32:31.075Z" },
+ { url = "https://files.pythonhosted.org/packages/85/c8/c14265212436da8e0814c45463987b3f57de3eca4de023cc2eabb0c62ef3/time_machine-3.2.0-cp313-cp313t-win32.whl", hash = "sha256:3498719f8dab51da76d29a20c1b5e52ee7db083dddf3056af7fa69c1b94e1fe6", size = 17852, upload-time = "2025-12-17T23:32:32.079Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/bc/8acb13cf6149f47508097b158a9a8bec9ec4530a70cb406124e8023581f5/time_machine-3.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:e0d90bee170b219e1d15e6a58164aa808f5170090e4f090bd0670303e34181b1", size = 18918, upload-time = "2025-12-17T23:32:33.106Z" },
+ { url = "https://files.pythonhosted.org/packages/24/87/c443ee508c2708fd2514ccce9052f5e48888783ce690506919629ebc8eb0/time_machine-3.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:051de220fdb6e20d648111bbad423d9506fdbb2e44d4429cef3dc0382abf1fc2", size = 17261, upload-time = "2025-12-17T23:32:34.446Z" },
+ { url = "https://files.pythonhosted.org/packages/61/70/b4b980d126ed155c78d1879c50d60c8dcbd47bd11cb14ee7be50e0dfc07f/time_machine-3.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:1398980c017fe5744d66f419e0115ee48a53b00b146d738e1416c225eb610b82", size = 19303, upload-time = "2025-12-17T23:32:35.796Z" },
+ { url = "https://files.pythonhosted.org/packages/73/73/eaa33603c69a68fe2b6f54f9dd75481693d62f1d29676531002be06e2d1c/time_machine-3.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4f8f4e35f4191ef70c2ab8ff490761ee9051b891afce2bf86dde3918eb7b537b", size = 15431, upload-time = "2025-12-17T23:32:37.244Z" },
+ { url = "https://files.pythonhosted.org/packages/76/10/b81e138e86cc7bab40cdb59d294b341e172201f4a6c84bb0ec080407977a/time_machine-3.2.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6db498686ecf6163c5aa8cf0bcd57bbe0f4081184f247edf3ee49a2612b584f9", size = 33206, upload-time = "2025-12-17T23:32:38.713Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/72/4deab446b579e8bd5dca91de98595c5d6bd6a17ce162abf5c5f2ce40d3d8/time_machine-3.2.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:027c1807efb74d0cd58ad16524dec94212fbe900115d70b0123399883657ac0f", size = 34792, upload-time = "2025-12-17T23:32:40.223Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/39/439c6b587ddee76d533fe972289d0646e0a5520e14dc83d0a30aeb5565f7/time_machine-3.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92432610c05676edd5e6946a073c6f0c926923123ce7caee1018dc10782c713d", size = 36187, upload-time = "2025-12-17T23:32:41.705Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/db/2da4368db15180989bab83746a857bde05ad16e78f326801c142bb747a06/time_machine-3.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c25586b62480eb77ef3d953fba273209478e1ef49654592cd6a52a68dfe56a67", size = 34855, upload-time = "2025-12-17T23:32:42.817Z" },
+ { url = "https://files.pythonhosted.org/packages/88/84/120a431fee50bc4c241425bee4d3a4910df4923b7ab5f7dff1bf0c772f08/time_machine-3.2.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6bf3a2fa738d15e0b95d14469a0b8ea42635467408d8b490e263d5d45c9a177f", size = 33222, upload-time = "2025-12-17T23:32:43.94Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/ea/89cfda82bb8c57ff91bb9a26751aa234d6d90e9b4d5ab0ad9dce0f9f0329/time_machine-3.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ce76b82276d7ad2a66cdc85dad4df19d1422b69183170a34e8fbc4c3f35502f7", size = 34270, upload-time = "2025-12-17T23:32:45.037Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/aa/235357da4f69a51a8d35fcbfcfa77cdc7dc24f62ae54025006570bda7e2d/time_machine-3.2.0-cp314-cp314-win32.whl", hash = "sha256:14d6778273c543441863dff712cd1d7803dee946b18de35921eb8df10714539d", size = 17544, upload-time = "2025-12-17T23:32:46.099Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/51/6c8405a7276be79693b792cff22ce41067ec05db26a7d02f2d5b06324434/time_machine-3.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:cbf821da96dbc80d349fa9e7c36e670b41d68a878d28c8850057992fed430eef", size = 18423, upload-time = "2025-12-17T23:32:47.468Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/03/a3cf419e20c35fc203c6e4fed48b5b667c1a2b4da456d9971e605f73ecef/time_machine-3.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:71c75d71f8e68abc8b669bca26ed2ddd558430a6c171e32b8620288565f18c0e", size = 17050, upload-time = "2025-12-17T23:32:48.91Z" },
+ { url = "https://files.pythonhosted.org/packages/86/a1/142de946dc4393f910bf4564b5c3ba819906e1f49b06c9cb557519c849e4/time_machine-3.2.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4e374779021446fc2b5c29d80457ec9a3b1a5df043dc2aae07d7c1415d52323c", size = 19991, upload-time = "2025-12-17T23:32:49.933Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/62/7f17def6289901f94726921811a16b9adce46e666362c75d45730c60274f/time_machine-3.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:122310a6af9c36e9a636da32830e591e7923e8a07bdd0a43276c3a36c6821c90", size = 15707, upload-time = "2025-12-17T23:32:50.969Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/d3/3502fb9bd3acb159c18844b26c43220201a0d4a622c0c853785d07699a92/time_machine-3.2.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ba3eeb0f018cc362dd8128befa3426696a2e16dd223c3fb695fde184892d4d8c", size = 39207, upload-time = "2025-12-17T23:32:52.033Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/be/8b27f4aa296fda14a5a2ad7f588ddd450603c33415ab3f8e85b2f1a44678/time_machine-3.2.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:77d38ba664b381a7793f8786efc13b5004f0d5f672dae814430445b8202a67a6", size = 40764, upload-time = "2025-12-17T23:32:53.167Z" },
+ { url = "https://files.pythonhosted.org/packages/42/cd/fe4c4e5c8ab6d48fab3624c32be9116fb120173a35fe67e482e5cf68b3d2/time_machine-3.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f09abeb8f03f044d72712207e0489a62098ad3ad16dac38927fcf80baca4d6a7", size = 43508, upload-time = "2025-12-17T23:32:54.597Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/28/5a3ba2fce85b97655a425d6bb20a441550acd2b304c96b2c19d3839f721a/time_machine-3.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6b28367ce4f73987a55e230e1d30a57a3af85da8eb1a140074eb6e8c7e6ef19f", size = 41712, upload-time = "2025-12-17T23:32:55.781Z" },
+ { url = "https://files.pythonhosted.org/packages/81/58/e38084be7fdabb4835db68a3a47e58c34182d79fc35df1ecbe0db2c5359f/time_machine-3.2.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:903c7751c904581da9f7861c3015bed7cdc40047321291d3694a3cdc783bbca3", size = 38939, upload-time = "2025-12-17T23:32:56.867Z" },
+ { url = "https://files.pythonhosted.org/packages/40/d0/ad3feb0a392ef4e0c08bc32024950373ddc0669002cbdcbb9f3bf0c2d114/time_machine-3.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:528217cad85ede5f85c8bc78b0341868d3c3cfefc6ecb5b622e1cacb6c73247b", size = 39837, upload-time = "2025-12-17T23:32:58.283Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/9e/5f4b2ea63b267bd78f3245e76f5528836611b5f2d30b5e7300a722fe4428/time_machine-3.2.0-cp314-cp314t-win32.whl", hash = "sha256:75724762ffd517e7e80aaec1fad1ff5a7414bd84e2b3ee7a0bacfeb67c14926e", size = 18091, upload-time = "2025-12-17T23:32:59.403Z" },
+ { url = "https://files.pythonhosted.org/packages/39/6f/456b1f4d2700ae02b19eba830f870596a4b89b74bac3b6c80666f1b108c5/time_machine-3.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2526abbd053c5bca898d1b3e7898eec34626b12206718d8c7ce88fd12c1c9c5c", size = 19208, upload-time = "2025-12-17T23:33:00.488Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/22/8063101427ecd3d2652aada4d21d0876b07a3dc789125bca2ee858fec3ed/time_machine-3.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:7f2fb6784b414edbe2c0b558bfaab0c251955ba27edd62946cce4a01675a992c", size = 17359, upload-time = "2025-12-17T23:33:01.54Z" },
+]
+
[[package]]
name = "tomli"
version = "2.4.1"