Compare commits

..

11 Commits

Author SHA1 Message Date
Jamison Lahman
b59f8cf453 feat(cli): onyx install-skill (#9889) 2026-04-03 12:41:39 -07:00
Bo-Onyx
456ecc7b9a feat(hook): UI improve disconnect error popover (#9877) 2026-04-03 19:15:19 +00:00
Jamison Lahman
fdc2bc9ee2 fix(fe): closed sidebar button tooltip text color (#9876) 2026-04-03 18:57:48 +00:00
Jamison Lahman
1c3f371549 fix(fe): projects buttons transition in like other sidebar items (#9875) 2026-04-03 18:50:14 +00:00
Evan Lohn
a120add37b feat: filestore delete missing error (#9878) 2026-04-03 18:19:41 +00:00
Raunak Bhagat
757e4e979b feat: cluster disabled admin sidebar tabs at the bottom (#9867) 2026-04-03 18:01:03 +00:00
Wenxi
cbcdfee56e fix(mcp server): propagate detailed error messages to mcp client instead of generic message and migrate to OnyxError (#9880) 2026-04-03 16:29:22 +00:00
Jamison Lahman
b06700314b fix(fe): fix sticky sidebar headers overlapping scrollbars (#9884) 2026-04-03 16:16:10 +00:00
roshan
01f573cdcb feat(cli): make onyx-cli agent-friendly (#9874)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 16:08:57 +00:00
Jamison Lahman
d4a96d70f3 fix(desktop): prefer native scrollbar styling (#9879) 2026-04-03 00:33:18 +00:00
Evan Lohn
5b000c2173 chore: remove unused db rows (#9869) 2026-04-02 22:17:10 +00:00
38 changed files with 1597 additions and 486 deletions

View File

@@ -1,186 +0,0 @@
---
name: onyx-cli
description: Query the Onyx knowledge base using the onyx-cli command. Use when the user wants to search company documents, ask questions about internal knowledge, query connected data sources, or look up information stored in Onyx.
---
# Onyx CLI — Agent Tool
Onyx is an enterprise search and Gen-AI platform that connects to company documents, apps, and people. The `onyx-cli` CLI provides non-interactive commands to query the Onyx knowledge base and list available agents.
## Prerequisites
### 1. Check if installed
```bash
which onyx-cli
```
### 2. Install (if needed)
**Primary — pip:**
```bash
pip install onyx-cli
```
**From source (Go):**
```bash
cd cli && go build -o onyx-cli . && sudo mv onyx-cli /usr/local/bin/
```
### 3. Check if configured
```bash
onyx-cli validate-config
```
This checks the config file exists, API key is present, and tests the server connection via `/api/me`. Exit code 0 on success, non-zero with a descriptive error on failure.
If unconfigured, you have two options:
**Option A — Interactive setup (requires user input):**
```bash
onyx-cli configure
```
This prompts for the Onyx server URL and API key, tests the connection, and saves config.
**Option B — Environment variables (non-interactive, preferred for agents):**
```bash
export ONYX_SERVER_URL="https://your-onyx-server.com" # default: https://cloud.onyx.app
export ONYX_API_KEY="your-api-key"
```
Environment variables override the config file. If these are set, no config file is needed.
| Variable | Required | Description |
|----------|----------|-------------|
| `ONYX_SERVER_URL` | No | Onyx server base URL (default: `https://cloud.onyx.app`) |
| `ONYX_API_KEY` | Yes | API key for authentication |
| `ONYX_PERSONA_ID` | No | Default agent/persona ID |
If neither the config file nor environment variables are set, tell the user that `onyx-cli` needs to be configured and ask them to either:
- Run `onyx-cli configure` interactively, or
- Set `ONYX_SERVER_URL` and `ONYX_API_KEY` environment variables
## Commands
### Validate configuration
```bash
onyx-cli validate-config
```
Checks config file exists, API key is present, and tests the server connection. Use this before `ask` or `agents` to confirm the CLI is properly set up.
### List available agents
```bash
onyx-cli agents
```
Prints a table of agent IDs, names, and descriptions. Use `--json` for structured output:
```bash
onyx-cli agents --json
```
Use agent IDs with `ask --agent-id` to query a specific agent.
### Basic query (plain text output)
```bash
onyx-cli ask "What is our company's PTO policy?"
```
Streams the answer as plain text to stdout. Exit code 0 on success, non-zero on error.
### JSON output (structured events)
```bash
onyx-cli ask --json "What authentication methods do we support?"
```
Outputs JSON-encoded parsed stream events (one object per line). Key event objects include message deltas, stop, errors, search-start, and citation payloads.
Each line is a JSON object with this envelope:
```json
{"type": "<event_type>", "event": { ... }}
```
| Event Type | Description |
|------------|-------------|
| `message_delta` | Content token — concatenate all `content` fields for the full answer |
| `stop` | Stream complete |
| `error` | Error with `error` message field |
| `search_tool_start` | Onyx started searching documents |
| `citation_info` | Source citation — see shape below |
`citation_info` event shape:
```json
{
"type": "citation_info",
"event": {
"citation_number": 1,
"document_id": "abc123def456",
"placement": {"turn_index": 0, "tab_index": 0, "sub_turn_index": null}
}
}
```
`placement` is metadata about where in the conversation the citation appeared and can be ignored for most use cases.
### Specify an agent
```bash
onyx-cli ask --agent-id 5 "Summarize our Q4 roadmap"
```
Uses a specific Onyx agent/persona instead of the default.
### All flags
| Flag | Type | Description |
|------|------|-------------|
| `--agent-id` | int | Agent ID to use (overrides default) |
| `--json` | bool | Output raw NDJSON events instead of plain text |
## Statelessness
Each `onyx-cli ask` call creates an independent chat session. There is no built-in way to chain context across multiple `ask` invocations — every call starts fresh. If you need multi-turn conversation with memory, use the interactive TUI (`onyx-cli` or `onyx-cli chat`) instead.
## When to Use
Use `onyx-cli ask` when:
- The user asks about company-specific information (policies, docs, processes)
- You need to search internal knowledge bases or connected data sources
- The user references Onyx, asks you to "search Onyx", or wants to query their documents
- You need context from company wikis, Confluence, Google Drive, Slack, or other connected sources
Do NOT use when:
- The question is about general programming knowledge (use your own knowledge)
- The user is asking about code in the current repository (use grep/read tools)
- The user hasn't mentioned Onyx and the question doesn't require internal company data
## Examples
```bash
# Simple question
onyx-cli ask "What are the steps to deploy to production?"
# Get structured output for parsing
onyx-cli ask --json "List all active API integrations"
# Use a specialized agent
onyx-cli ask --agent-id 3 "What were the action items from last week's standup?"
# Pipe the answer into another command
onyx-cli ask "What is the database schema for users?" | head -20
```

View File

@@ -0,0 +1 @@
../../../cli/internal/embedded/SKILL.md

View File

@@ -1,20 +1,14 @@
from datetime import datetime
from datetime import timezone
from uuid import UUID
from celery import shared_task
from celery import Task
from ee.onyx.background.celery_utils import should_perform_chat_ttl_check
from ee.onyx.background.task_name_builders import name_chat_ttl_task
from onyx.configs.app_configs import JOB_TIMEOUT
from onyx.configs.constants import OnyxCeleryTask
from onyx.db.chat import delete_chat_session
from onyx.db.chat import get_chat_sessions_older_than
from onyx.db.engine.sql_engine import get_session_with_current_tenant
from onyx.db.enums import TaskStatus
from onyx.db.tasks import mark_task_as_finished_with_id
from onyx.db.tasks import register_task
from onyx.server.settings.store import load_settings
from onyx.utils.logger import setup_logger
@@ -29,59 +23,42 @@ logger = setup_logger()
trail=False,
)
def perform_ttl_management_task(
self: Task, retention_limit_days: int, *, tenant_id: str
self: Task, retention_limit_days: int, *, tenant_id: str # noqa: ARG001
) -> None:
task_id = self.request.id
if not task_id:
raise RuntimeError("No task id defined for this task; cannot identify it")
start_time = datetime.now(tz=timezone.utc)
user_id: UUID | None = None
session_id: UUID | None = None
try:
with get_session_with_current_tenant() as db_session:
# we generally want to move off this, but keeping for now
register_task(
db_session=db_session,
task_name=name_chat_ttl_task(retention_limit_days, tenant_id),
task_id=task_id,
status=TaskStatus.STARTED,
start_time=start_time,
)
old_chat_sessions = get_chat_sessions_older_than(
retention_limit_days, db_session
)
for user_id, session_id in old_chat_sessions:
# one session per delete so that we don't blow up if a deletion fails.
with get_session_with_current_tenant() as db_session:
delete_chat_session(
user_id,
session_id,
db_session,
include_deleted=True,
hard_delete=True,
try:
with get_session_with_current_tenant() as db_session:
delete_chat_session(
user_id,
session_id,
db_session,
include_deleted=True,
hard_delete=True,
)
except Exception:
logger.exception(
"Failed to delete chat session "
f"user_id={user_id} session_id={session_id}, "
"continuing with remaining sessions"
)
with get_session_with_current_tenant() as db_session:
mark_task_as_finished_with_id(
db_session=db_session,
task_id=task_id,
success=True,
)
except Exception:
logger.exception(
f"delete_chat_session exceptioned. user_id={user_id} session_id={session_id}"
)
with get_session_with_current_tenant() as db_session:
mark_task_as_finished_with_id(
db_session=db_session,
task_id=task_id,
success=False,
)
raise

View File

@@ -199,7 +199,7 @@ def delete_messages_and_files_from_chat_session(
for _, files in messages_with_files:
file_store = get_default_file_store()
for file_info in files or []:
file_store.delete_file(file_id=file_info.get("id"))
file_store.delete_file(file_id=file_info.get("id"), error_on_missing=False)
# Delete ChatMessage records - CASCADE constraints will automatically handle:
# - ChatMessage__StandardAnswer relationship records

View File

@@ -136,12 +136,14 @@ class FileStore(ABC):
"""
@abstractmethod
def delete_file(self, file_id: str) -> None:
def delete_file(self, file_id: str, error_on_missing: bool = True) -> None:
"""
Delete a file by its ID.
Parameters:
- file_name: Name of file to delete
- file_id: ID of file to delete
- error_on_missing: If False, silently return when the file record
does not exist instead of raising.
"""
@abstractmethod
@@ -452,12 +454,23 @@ class S3BackedFileStore(FileStore):
logger.warning(f"Error getting file size for {file_id}: {e}")
return None
def delete_file(self, file_id: str, db_session: Session | None = None) -> None:
def delete_file(
self,
file_id: str,
error_on_missing: bool = True,
db_session: Session | None = None,
) -> None:
with get_session_with_current_tenant_if_none(db_session) as db_session:
try:
file_record = get_filerecord_by_file_id(
file_record = get_filerecord_by_file_id_optional(
file_id=file_id, db_session=db_session
)
if file_record is None:
if error_on_missing:
raise RuntimeError(
f"File by id {file_id} does not exist or was deleted"
)
return
if not file_record.bucket_name:
logger.error(
f"File record {file_id} with key {file_record.object_key} "

View File

@@ -222,12 +222,23 @@ class PostgresBackedFileStore(FileStore):
logger.warning(f"Error getting file size for {file_id}: {e}")
return None
def delete_file(self, file_id: str, db_session: Session | None = None) -> None:
def delete_file(
self,
file_id: str,
error_on_missing: bool = True,
db_session: Session | None = None,
) -> None:
with get_session_with_current_tenant_if_none(db_session) as session:
try:
file_content = get_file_content_by_file_id(
file_content = get_file_content_by_file_id_optional(
file_id=file_id, db_session=session
)
if file_content is None:
if error_on_missing:
raise RuntimeError(
f"File content for file_id {file_id} does not exist or was deleted"
)
return
raw_conn = _get_raw_connection(session)
try:

View File

@@ -3,6 +3,8 @@
from datetime import datetime
from typing import Any
import httpx
from onyx.configs.constants import DocumentSource
from onyx.mcp_server.api import mcp_server
from onyx.mcp_server.utils import get_http_client
@@ -15,6 +17,21 @@ from onyx.utils.variable_functionality import global_version
logger = setup_logger()
def _extract_error_detail(response: httpx.Response) -> str:
"""Extract a human-readable error message from a failed backend response.
The backend returns OnyxError responses as
``{"error_code": "...", "detail": "..."}``.
"""
try:
body = response.json()
if detail := body.get("detail"):
return str(detail)
except Exception:
pass
return f"Request failed with status {response.status_code}"
@mcp_server.tool()
async def search_indexed_documents(
query: str,
@@ -158,7 +175,14 @@ async def search_indexed_documents(
json=search_request,
headers=auth_headers,
)
response.raise_for_status()
if not response.is_success:
error_detail = _extract_error_detail(response)
return {
"documents": [],
"total_results": 0,
"query": query,
"error": error_detail,
}
result = response.json()
# Check for error in response
@@ -234,7 +258,13 @@ async def search_web(
json=request_payload,
headers={"Authorization": f"Bearer {access_token.token}"},
)
response.raise_for_status()
if not response.is_success:
error_detail = _extract_error_detail(response)
return {
"error": error_detail,
"results": [],
"query": query,
}
response_payload = response.json()
results = response_payload.get("results", [])
return {
@@ -280,7 +310,12 @@ async def open_urls(
json={"urls": urls},
headers={"Authorization": f"Bearer {access_token.token}"},
)
response.raise_for_status()
if not response.is_success:
error_detail = _extract_error_detail(response)
return {
"error": error_detail,
"results": [],
}
response_payload = response.json()
results = response_payload.get("results", [])
return {

View File

@@ -1,6 +1,5 @@
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from sqlalchemy.orm import Session
from onyx.auth.users import current_user
@@ -9,6 +8,8 @@ from onyx.db.engine.sql_engine import get_session
from onyx.db.models import User
from onyx.db.web_search import fetch_active_web_content_provider
from onyx.db.web_search import fetch_active_web_search_provider
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.server.features.web_search.models import OpenUrlsToolRequest
from onyx.server.features.web_search.models import OpenUrlsToolResponse
from onyx.server.features.web_search.models import WebSearchToolRequest
@@ -61,9 +62,10 @@ def _get_active_search_provider(
) -> tuple[WebSearchProviderView, WebSearchProvider]:
provider_model = fetch_active_web_search_provider(db_session)
if provider_model is None:
raise HTTPException(
status_code=400,
detail="No web search provider configured.",
raise OnyxError(
OnyxErrorCode.INVALID_INPUT,
"No web search provider configured. Please configure one in "
"Admin > Web Search settings.",
)
provider_view = WebSearchProviderView(
@@ -76,9 +78,10 @@ def _get_active_search_provider(
)
if provider_model.api_key is None:
raise HTTPException(
status_code=400,
detail="Web search provider requires an API key.",
raise OnyxError(
OnyxErrorCode.INVALID_INPUT,
"Web search provider requires an API key. Please configure one in "
"Admin > Web Search settings.",
)
try:
@@ -88,7 +91,7 @@ def _get_active_search_provider(
config=provider_model.config or {},
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
raise OnyxError(OnyxErrorCode.INVALID_INPUT, str(exc)) from exc
return provider_view, provider
@@ -110,9 +113,9 @@ def _get_active_content_provider(
if provider_model.api_key is None:
# TODO - this is not a great error, in fact, this key should not be nullable.
raise HTTPException(
status_code=400,
detail="Web content provider requires an API key.",
raise OnyxError(
OnyxErrorCode.INVALID_INPUT,
"Web content provider requires an API key.",
)
try:
@@ -125,12 +128,12 @@ def _get_active_content_provider(
config=config,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
raise OnyxError(OnyxErrorCode.INVALID_INPUT, str(exc)) from exc
if provider is None:
raise HTTPException(
status_code=400,
detail="Unable to initialize the configured web content provider.",
raise OnyxError(
OnyxErrorCode.INVALID_INPUT,
"Unable to initialize the configured web content provider.",
)
provider_view = WebContentProviderView(
@@ -154,12 +157,13 @@ def _run_web_search(
for query in request.queries:
try:
search_results = provider.search(query)
except HTTPException:
except OnyxError:
raise
except Exception as exc:
logger.exception("Web search provider failed for query '%s'", query)
raise HTTPException(
status_code=502, detail="Web search provider failed to execute query."
raise OnyxError(
OnyxErrorCode.BAD_GATEWAY,
"Web search provider failed to execute query.",
) from exc
filtered_results = filter_web_search_results_with_no_title_or_snippet(
@@ -192,12 +196,13 @@ def _open_urls(
docs = filter_web_contents_with_no_title_or_content(
list(provider.contents(urls))
)
except HTTPException:
except OnyxError:
raise
except Exception as exc:
logger.exception("Web content provider failed to fetch URLs")
raise HTTPException(
status_code=502, detail="Web content provider failed to fetch URLs."
raise OnyxError(
OnyxErrorCode.BAD_GATEWAY,
"Web content provider failed to fetch URLs.",
) from exc
results: list[LlmOpenUrlResult] = []

View File

@@ -0,0 +1,91 @@
"""Tests for FileStore.delete_file error_on_missing behavior."""
from unittest.mock import MagicMock
from unittest.mock import patch
import pytest
_S3_MODULE = "onyx.file_store.file_store"
_PG_MODULE = "onyx.file_store.postgres_file_store"
def _mock_db_session() -> MagicMock:
session = MagicMock()
session.__enter__ = MagicMock(return_value=session)
session.__exit__ = MagicMock(return_value=False)
return session
# ── S3BackedFileStore ────────────────────────────────────────────────
@patch(f"{_S3_MODULE}.get_session_with_current_tenant_if_none")
@patch(f"{_S3_MODULE}.get_filerecord_by_file_id_optional", return_value=None)
def test_s3_delete_missing_file_raises_by_default(
_mock_get_record: MagicMock,
mock_ctx: MagicMock,
) -> None:
from onyx.file_store.file_store import S3BackedFileStore
mock_ctx.return_value = _mock_db_session()
store = S3BackedFileStore(bucket_name="b")
with pytest.raises(RuntimeError, match="does not exist"):
store.delete_file("nonexistent")
@patch(f"{_S3_MODULE}.get_session_with_current_tenant_if_none")
@patch(f"{_S3_MODULE}.get_filerecord_by_file_id_optional", return_value=None)
@patch(f"{_S3_MODULE}.delete_filerecord_by_file_id")
def test_s3_delete_missing_file_silent_when_error_on_missing_false(
mock_delete_record: MagicMock,
_mock_get_record: MagicMock,
mock_ctx: MagicMock,
) -> None:
from onyx.file_store.file_store import S3BackedFileStore
mock_ctx.return_value = _mock_db_session()
store = S3BackedFileStore(bucket_name="b")
store.delete_file("nonexistent", error_on_missing=False)
mock_delete_record.assert_not_called()
# ── PostgresBackedFileStore ──────────────────────────────────────────
@patch(f"{_PG_MODULE}.get_session_with_current_tenant_if_none")
@patch(f"{_PG_MODULE}.get_file_content_by_file_id_optional", return_value=None)
def test_pg_delete_missing_file_raises_by_default(
_mock_get_content: MagicMock,
mock_ctx: MagicMock,
) -> None:
from onyx.file_store.postgres_file_store import PostgresBackedFileStore
mock_ctx.return_value = _mock_db_session()
store = PostgresBackedFileStore()
with pytest.raises(RuntimeError, match="does not exist"):
store.delete_file("nonexistent")
@patch(f"{_PG_MODULE}.get_session_with_current_tenant_if_none")
@patch(f"{_PG_MODULE}.get_file_content_by_file_id_optional", return_value=None)
@patch(f"{_PG_MODULE}.delete_file_content_by_file_id")
@patch(f"{_PG_MODULE}.delete_filerecord_by_file_id")
def test_pg_delete_missing_file_silent_when_error_on_missing_false(
mock_delete_record: MagicMock,
mock_delete_content: MagicMock,
_mock_get_content: MagicMock,
mock_ctx: MagicMock,
) -> None:
from onyx.file_store.postgres_file_store import PostgresBackedFileStore
mock_ctx.return_value = _mock_db_session()
store = PostgresBackedFileStore()
store.delete_file("nonexistent", error_on_missing=False)
mock_delete_record.assert_not_called()
mock_delete_content.assert_not_called()

View File

@@ -98,6 +98,7 @@ Useful hardening flags:
| `serve` | Serve the interactive chat TUI over SSH |
| `configure` | Configure server URL and API key |
| `validate-config` | Validate configuration and test connection |
| `install-skill` | Install the agent skill file into a project |
## Slash Commands (in TUI)

View File

@@ -7,6 +7,7 @@ import (
"github.com/onyx-dot-app/onyx/cli/internal/api"
"github.com/onyx-dot-app/onyx/cli/internal/config"
"github.com/onyx-dot-app/onyx/cli/internal/exitcodes"
"github.com/spf13/cobra"
)
@@ -16,16 +17,23 @@ func newAgentsCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "agents",
Short: "List available agents",
Long: `List all visible agents configured on the Onyx server.
By default, output is a human-readable table with ID, name, and description.
Use --json for machine-readable output.`,
Example: ` onyx-cli agents
onyx-cli agents --json
onyx-cli agents --json | jq '.[].name'`,
RunE: func(cmd *cobra.Command, args []string) error {
cfg := config.Load()
if !cfg.IsConfigured() {
return fmt.Errorf("onyx CLI is not configured — run 'onyx-cli configure' first")
return exitcodes.New(exitcodes.NotConfigured, "onyx CLI is not configured\n Run: onyx-cli configure")
}
client := api.NewClient(cfg)
agents, err := client.ListAgents(cmd.Context())
if err != nil {
return fmt.Errorf("failed to list agents: %w", err)
return fmt.Errorf("failed to list agents: %w\n Check your connection with: onyx-cli validate-config", err)
}
if agentsJSON {

View File

@@ -4,33 +4,65 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"os/signal"
"strings"
"syscall"
"github.com/onyx-dot-app/onyx/cli/internal/api"
"github.com/onyx-dot-app/onyx/cli/internal/config"
"github.com/onyx-dot-app/onyx/cli/internal/exitcodes"
"github.com/onyx-dot-app/onyx/cli/internal/models"
"github.com/onyx-dot-app/onyx/cli/internal/overflow"
"github.com/spf13/cobra"
"golang.org/x/term"
)
const defaultMaxOutputBytes = 4096
func newAskCmd() *cobra.Command {
var (
askAgentID int
askJSON bool
askQuiet bool
askPrompt string
maxOutput int
)
cmd := &cobra.Command{
Use: "ask [question]",
Short: "Ask a one-shot question (non-interactive)",
Args: cobra.ExactArgs(1),
Long: `Send a one-shot question to an Onyx agent and print the response.
The question can be provided as a positional argument, via --prompt, or piped
through stdin. When stdin contains piped data, it is sent as context along
with the question from --prompt (or used as the question itself).
When stdout is not a TTY (e.g., called by a script or AI agent), output is
automatically truncated to --max-output bytes and the full response is saved
to a temp file. Set --max-output 0 to disable truncation.`,
Args: cobra.MaximumNArgs(1),
Example: ` onyx-cli ask "What connectors are available?"
onyx-cli ask --agent-id 3 "Summarize our Q4 revenue"
onyx-cli ask --json "List all users" | jq '.event.content'
cat error.log | onyx-cli ask --prompt "Find the root cause"
echo "what is onyx?" | onyx-cli ask`,
RunE: func(cmd *cobra.Command, args []string) error {
cfg := config.Load()
if !cfg.IsConfigured() {
return fmt.Errorf("onyx CLI is not configured — run 'onyx-cli configure' first")
return exitcodes.New(exitcodes.NotConfigured, "onyx CLI is not configured\n Run: onyx-cli configure")
}
if askJSON && askQuiet {
return exitcodes.New(exitcodes.BadRequest, "--json and --quiet cannot be used together")
}
question, err := resolveQuestion(args, askPrompt)
if err != nil {
return err
}
question := args[0]
agentID := cfg.DefaultAgentID
if cmd.Flags().Changed("agent-id") {
agentID = askAgentID
@@ -50,9 +82,23 @@ func newAskCmd() *cobra.Command {
nil,
)
// Determine truncation threshold.
isTTY := term.IsTerminal(int(os.Stdout.Fd()))
truncateAt := 0 // 0 means no truncation
if cmd.Flags().Changed("max-output") {
truncateAt = maxOutput
} else if !isTTY {
truncateAt = defaultMaxOutputBytes
}
var sessionID string
var lastErr error
gotStop := false
// Overflow writer: tees to stdout and optionally to a temp file.
// In quiet mode, buffer everything and print once at the end.
ow := &overflow.Writer{Limit: truncateAt, Quiet: askQuiet}
for event := range ch {
if e, ok := event.(models.SessionCreatedEvent); ok {
sessionID = e.ChatSessionID
@@ -82,22 +128,50 @@ func newAskCmd() *cobra.Command {
switch e := event.(type) {
case models.MessageDeltaEvent:
fmt.Print(e.Content)
ow.Write(e.Content)
case models.SearchStartEvent:
if isTTY && !askQuiet {
if e.IsInternetSearch {
fmt.Fprintf(os.Stderr, "\033[2mSearching the web...\033[0m\n")
} else {
fmt.Fprintf(os.Stderr, "\033[2mSearching documents...\033[0m\n")
}
}
case models.SearchQueriesEvent:
if isTTY && !askQuiet {
for _, q := range e.Queries {
fmt.Fprintf(os.Stderr, "\033[2m → %s\033[0m\n", q)
}
}
case models.SearchDocumentsEvent:
if isTTY && !askQuiet && len(e.Documents) > 0 {
fmt.Fprintf(os.Stderr, "\033[2mFound %d documents\033[0m\n", len(e.Documents))
}
case models.ReasoningStartEvent:
if isTTY && !askQuiet {
fmt.Fprintf(os.Stderr, "\033[2mThinking...\033[0m\n")
}
case models.ToolStartEvent:
if isTTY && !askQuiet && e.ToolName != "" {
fmt.Fprintf(os.Stderr, "\033[2mUsing %s...\033[0m\n", e.ToolName)
}
case models.ErrorEvent:
ow.Finish()
return fmt.Errorf("%s", e.Error)
case models.StopEvent:
fmt.Println()
ow.Finish()
return nil
}
}
if !askJSON {
ow.Finish()
}
if ctx.Err() != nil {
if sessionID != "" {
client.StopChatSession(context.Background(), sessionID)
}
if !askJSON {
fmt.Println()
}
return nil
}
@@ -105,20 +179,56 @@ func newAskCmd() *cobra.Command {
return lastErr
}
if !gotStop {
if !askJSON {
fmt.Println()
}
return fmt.Errorf("stream ended unexpectedly")
}
if !askJSON {
fmt.Println()
}
return nil
},
}
cmd.Flags().IntVar(&askAgentID, "agent-id", 0, "Agent ID to use")
cmd.Flags().BoolVar(&askJSON, "json", false, "Output raw JSON events")
// Suppress cobra's default error/usage on RunE errors
cmd.Flags().BoolVarP(&askQuiet, "quiet", "q", false, "Buffer output and print once at end (no streaming)")
cmd.Flags().StringVar(&askPrompt, "prompt", "", "Question text (use with piped stdin context)")
cmd.Flags().IntVar(&maxOutput, "max-output", defaultMaxOutputBytes,
"Max bytes to print before truncating (0 to disable, auto-enabled for non-TTY)")
return cmd
}
// resolveQuestion builds the final question string from args, --prompt, and stdin.
func resolveQuestion(args []string, prompt string) (string, error) {
hasArg := len(args) > 0
hasPrompt := prompt != ""
hasStdin := !term.IsTerminal(int(os.Stdin.Fd()))
if hasArg && hasPrompt {
return "", exitcodes.New(exitcodes.BadRequest, "specify the question as an argument or --prompt, not both")
}
var stdinContent string
if hasStdin {
const maxStdinBytes = 10 * 1024 * 1024 // 10MB
data, err := io.ReadAll(io.LimitReader(os.Stdin, maxStdinBytes))
if err != nil {
return "", fmt.Errorf("failed to read stdin: %w", err)
}
stdinContent = strings.TrimSpace(string(data))
}
switch {
case hasArg && stdinContent != "":
// arg is the question, stdin is context
return args[0] + "\n\n" + stdinContent, nil
case hasArg:
return args[0], nil
case hasPrompt && stdinContent != "":
// --prompt is the question, stdin is context
return prompt + "\n\n" + stdinContent, nil
case hasPrompt:
return prompt, nil
case stdinContent != "":
return stdinContent, nil
default:
return "", exitcodes.New(exitcodes.BadRequest, "no question provided\n Usage: onyx-cli ask \"your question\"\n Or: echo \"context\" | onyx-cli ask --prompt \"your question\"")
}
}

View File

@@ -13,6 +13,11 @@ func newChatCmd() *cobra.Command {
return &cobra.Command{
Use: "chat",
Short: "Launch the interactive chat TUI (default)",
Long: `Launch the interactive terminal UI for chatting with your Onyx agent.
This is the default command when no subcommand is specified. On first run,
an interactive setup wizard will guide you through configuration.`,
Example: ` onyx-cli chat
onyx-cli`,
RunE: func(cmd *cobra.Command, args []string) error {
cfg := config.Load()

View File

@@ -1,19 +1,126 @@
package cmd
import (
"context"
"errors"
"fmt"
"io"
"os"
"strings"
"time"
"github.com/onyx-dot-app/onyx/cli/internal/api"
"github.com/onyx-dot-app/onyx/cli/internal/config"
"github.com/onyx-dot-app/onyx/cli/internal/exitcodes"
"github.com/onyx-dot-app/onyx/cli/internal/onboarding"
"github.com/spf13/cobra"
"golang.org/x/term"
)
func newConfigureCmd() *cobra.Command {
return &cobra.Command{
var (
serverURL string
apiKey string
apiKeyStdin bool
dryRun bool
)
cmd := &cobra.Command{
Use: "configure",
Short: "Configure server URL and API key",
Long: `Set up the Onyx CLI with your server URL and API key.
When --server-url and --api-key are both provided, the configuration is saved
non-interactively (useful for scripts and AI agents). Otherwise, an interactive
setup wizard is launched.
If --api-key is omitted but stdin has piped data, the API key is read from
stdin automatically. You can also use --api-key-stdin to make this explicit.
This avoids leaking the key in shell history.
Use --dry-run to test the connection without saving the configuration.`,
Example: ` onyx-cli configure
onyx-cli configure --server-url https://my-onyx.com --api-key sk-...
echo "$ONYX_API_KEY" | onyx-cli configure --server-url https://my-onyx.com
echo "$ONYX_API_KEY" | onyx-cli configure --server-url https://my-onyx.com --api-key-stdin
onyx-cli configure --server-url https://my-onyx.com --api-key sk-... --dry-run`,
RunE: func(cmd *cobra.Command, args []string) error {
// Read API key from stdin if piped (implicit) or --api-key-stdin (explicit)
if apiKeyStdin && apiKey != "" {
return exitcodes.New(exitcodes.BadRequest, "--api-key and --api-key-stdin cannot be used together")
}
if (apiKey == "" && !term.IsTerminal(int(os.Stdin.Fd()))) || apiKeyStdin {
data, err := io.ReadAll(os.Stdin)
if err != nil {
return fmt.Errorf("failed to read API key from stdin: %w", err)
}
apiKey = strings.TrimSpace(string(data))
}
if serverURL != "" && apiKey != "" {
return configureNonInteractive(serverURL, apiKey, dryRun)
}
if dryRun {
return exitcodes.New(exitcodes.BadRequest, "--dry-run requires --server-url and --api-key")
}
if serverURL != "" || apiKey != "" {
return exitcodes.New(exitcodes.BadRequest, "both --server-url and --api-key are required for non-interactive setup\n Run 'onyx-cli configure' without flags for interactive setup")
}
cfg := config.Load()
onboarding.Run(&cfg)
return nil
},
}
cmd.Flags().StringVar(&serverURL, "server-url", "", "Onyx server URL (e.g., https://cloud.onyx.app)")
cmd.Flags().StringVar(&apiKey, "api-key", "", "API key for authentication (or pipe via stdin)")
cmd.Flags().BoolVar(&apiKeyStdin, "api-key-stdin", false, "Read API key from stdin (explicit; also happens automatically when stdin is piped)")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Test connection without saving config (requires --server-url and --api-key)")
return cmd
}
func configureNonInteractive(serverURL, apiKey string, dryRun bool) error {
cfg := config.OnyxCliConfig{
ServerURL: serverURL,
APIKey: apiKey,
DefaultAgentID: 0,
}
// Preserve existing default agent ID from disk (not env overrides)
if existing := config.LoadFromDisk(); existing.DefaultAgentID != 0 {
cfg.DefaultAgentID = existing.DefaultAgentID
}
// Test connection
client := api.NewClient(cfg)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := client.TestConnection(ctx); err != nil {
var authErr *api.AuthError
if errors.As(err, &authErr) {
return exitcodes.Newf(exitcodes.AuthFailure, "authentication failed: %v\n Check your API key", err)
}
return exitcodes.Newf(exitcodes.Unreachable, "connection failed: %v\n Check your server URL", err)
}
if dryRun {
fmt.Printf("Server: %s\n", serverURL)
fmt.Println("Status: connected and authenticated")
fmt.Println("Dry run: config was NOT saved")
return nil
}
if err := config.Save(cfg); err != nil {
return fmt.Errorf("could not save config: %w", err)
}
fmt.Printf("Config: %s\n", config.ConfigFilePath())
fmt.Printf("Server: %s\n", serverURL)
fmt.Println("Status: connected and authenticated")
return nil
}

176
cli/cmd/install_skill.go Normal file
View File

@@ -0,0 +1,176 @@
package cmd
import (
"fmt"
"os"
"path/filepath"
"github.com/onyx-dot-app/onyx/cli/internal/embedded"
"github.com/onyx-dot-app/onyx/cli/internal/fsutil"
"github.com/spf13/cobra"
)
// agentSkillDirs maps agent names to their skill directory paths (relative to
// the project or home root). "Universal" agents like Cursor and Codex read
// from .agents/skills directly, so they don't need their own entry here.
var agentSkillDirs = map[string]string{
"claude-code": filepath.Join(".claude", "skills"),
}
const (
canonicalDir = ".agents/skills"
skillName = "onyx-cli"
)
func newInstallSkillCmd() *cobra.Command {
var (
global bool
copyMode bool
agents []string
)
cmd := &cobra.Command{
Use: "install-skill",
Short: "Install the Onyx CLI agent skill file",
Long: `Install the bundled SKILL.md so that AI coding agents can discover and use
the Onyx CLI as a tool.
Files are written to the canonical .agents/skills/onyx-cli/ directory. For
agents that use their own skill directory (e.g. Claude Code uses .claude/skills/),
a symlink is created pointing back to the canonical copy.
By default the skill is installed at the project level (current directory).
Use --global to install under your home directory instead.
Use --copy to write independent copies instead of symlinks.
Use --agent to target specific agents (can be repeated).`,
Example: ` onyx-cli install-skill
onyx-cli install-skill --global
onyx-cli install-skill --agent claude-code
onyx-cli install-skill --copy`,
RunE: func(cmd *cobra.Command, args []string) error {
base, err := installBase(global)
if err != nil {
return err
}
// Write the canonical copy.
canonicalSkillDir := filepath.Join(base, canonicalDir, skillName)
dest := filepath.Join(canonicalSkillDir, "SKILL.md")
content := []byte(embedded.SkillMD)
status, err := fsutil.CompareFile(dest, content)
if err != nil {
return err
}
switch status {
case fsutil.StatusUpToDate:
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Up to date %s\n", dest)
case fsutil.StatusDiffers:
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: overwriting modified %s\n", dest)
if err := os.WriteFile(dest, content, 0o644); err != nil {
return fmt.Errorf("could not write skill file: %w", err)
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Installed %s\n", dest)
default: // statusMissing
if err := os.MkdirAll(canonicalSkillDir, 0o755); err != nil {
return fmt.Errorf("could not create directory: %w", err)
}
if err := os.WriteFile(dest, content, 0o644); err != nil {
return fmt.Errorf("could not write skill file: %w", err)
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Installed %s\n", dest)
}
// Determine which agents to link.
targets := agentSkillDirs
if len(agents) > 0 {
targets = make(map[string]string)
for _, a := range agents {
dir, ok := agentSkillDirs[a]
if !ok {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Unknown agent %q (skipped) — known agents:", a)
for name := range agentSkillDirs {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), " %s", name)
}
_, _ = fmt.Fprintln(cmd.ErrOrStderr())
continue
}
targets[a] = dir
}
}
// Create symlinks (or copies) from agent-specific dirs to canonical.
for name, skillsDir := range targets {
agentSkillDir := filepath.Join(base, skillsDir, skillName)
if copyMode {
copyDest := filepath.Join(agentSkillDir, "SKILL.md")
if err := fsutil.EnsureDirForCopy(agentSkillDir); err != nil {
return fmt.Errorf("could not prepare %s directory: %w", name, err)
}
if err := os.MkdirAll(agentSkillDir, 0o755); err != nil {
return fmt.Errorf("could not create %s directory: %w", name, err)
}
if err := os.WriteFile(copyDest, []byte(embedded.SkillMD), 0o644); err != nil {
return fmt.Errorf("could not write %s skill file: %w", name, err)
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Copied %s\n", copyDest)
continue
}
// Compute relative symlink target. Symlinks resolve relative to
// the parent directory of the link, not the link itself.
rel, err := filepath.Rel(filepath.Dir(agentSkillDir), canonicalSkillDir)
if err != nil {
return fmt.Errorf("could not compute relative path for %s: %w", name, err)
}
if err := os.MkdirAll(filepath.Dir(agentSkillDir), 0o755); err != nil {
return fmt.Errorf("could not create %s directory: %w", name, err)
}
// Remove existing symlink/dir before creating.
_ = os.Remove(agentSkillDir)
if err := os.Symlink(rel, agentSkillDir); err != nil {
// Fall back to copy if symlink fails (e.g. Windows without dev mode).
copyDest := filepath.Join(agentSkillDir, "SKILL.md")
if mkErr := os.MkdirAll(agentSkillDir, 0o755); mkErr != nil {
return fmt.Errorf("could not create %s directory: %w", name, mkErr)
}
if wErr := os.WriteFile(copyDest, []byte(embedded.SkillMD), 0o644); wErr != nil {
return fmt.Errorf("could not write %s skill file: %w", name, wErr)
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Copied %s (symlink failed)\n", copyDest)
continue
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Linked %s -> %s\n", agentSkillDir, rel)
}
return nil
},
}
cmd.Flags().BoolVarP(&global, "global", "g", false, "Install to home directory instead of project")
cmd.Flags().BoolVar(&copyMode, "copy", false, "Copy files instead of symlinking")
cmd.Flags().StringSliceVarP(&agents, "agent", "a", nil, "Target specific agents (e.g. claude-code)")
return cmd
}
func installBase(global bool) (string, error) {
if global {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("could not determine home directory: %w", err)
}
return home, nil
}
cwd, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("could not determine working directory: %w", err)
}
return cwd, nil
}

View File

@@ -97,6 +97,7 @@ func Execute() error {
rootCmd.AddCommand(newConfigureCmd())
rootCmd.AddCommand(newValidateConfigCmd())
rootCmd.AddCommand(newServeCmd())
rootCmd.AddCommand(newInstallSkillCmd())
// Default command is chat, but intercept --version first
rootCmd.RunE = func(cmd *cobra.Command, args []string) error {

View File

@@ -23,6 +23,7 @@ import (
"github.com/charmbracelet/wish/ratelimiter"
"github.com/onyx-dot-app/onyx/cli/internal/api"
"github.com/onyx-dot-app/onyx/cli/internal/config"
"github.com/onyx-dot-app/onyx/cli/internal/exitcodes"
"github.com/onyx-dot-app/onyx/cli/internal/tui"
"github.com/spf13/cobra"
"golang.org/x/time/rate"
@@ -295,15 +296,15 @@ provided via the ONYX_API_KEY environment variable to skip the prompt:
The server URL is taken from the server operator's config. The server
auto-generates an Ed25519 host key on first run if the key file does not
already exist. The host key path can also be set via the ONYX_SSH_HOST_KEY
environment variable (the --host-key flag takes precedence).
Example:
onyx-cli serve --port 2222
ssh localhost -p 2222`,
environment variable (the --host-key flag takes precedence).`,
Example: ` onyx-cli serve --port 2222
ssh localhost -p 2222
onyx-cli serve --host 0.0.0.0 --port 2222
onyx-cli serve --idle-timeout 30m --max-session-timeout 2h`,
RunE: func(cmd *cobra.Command, args []string) error {
serverCfg := config.Load()
if serverCfg.ServerURL == "" {
return fmt.Errorf("server URL is not configured; run 'onyx-cli configure' first")
return exitcodes.New(exitcodes.NotConfigured, "server URL is not configured\n Run: onyx-cli configure")
}
if !cmd.Flags().Changed("host-key") {
if v := os.Getenv(config.EnvSSHHostKey); v != "" {

View File

@@ -2,11 +2,13 @@ package cmd
import (
"context"
"errors"
"fmt"
"time"
"github.com/onyx-dot-app/onyx/cli/internal/api"
"github.com/onyx-dot-app/onyx/cli/internal/config"
"github.com/onyx-dot-app/onyx/cli/internal/exitcodes"
"github.com/onyx-dot-app/onyx/cli/internal/version"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
@@ -16,17 +18,21 @@ func newValidateConfigCmd() *cobra.Command {
return &cobra.Command{
Use: "validate-config",
Short: "Validate configuration and test server connection",
Long: `Check that the CLI is configured, the server is reachable, and the API key
is valid. Also reports the server version and warns if it is below the
minimum required.`,
Example: ` onyx-cli validate-config`,
RunE: func(cmd *cobra.Command, args []string) error {
// Check config file
if !config.ConfigExists() {
return fmt.Errorf("config file not found at %s\n Run 'onyx-cli configure' to set up", config.ConfigFilePath())
return exitcodes.Newf(exitcodes.NotConfigured, "config file not found at %s\n Run: onyx-cli configure", config.ConfigFilePath())
}
cfg := config.Load()
// Check API key
if !cfg.IsConfigured() {
return fmt.Errorf("API key is missing\n Run 'onyx-cli configure' to set up")
return exitcodes.New(exitcodes.NotConfigured, "API key is missing\n Run: onyx-cli configure")
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Config: %s\n", config.ConfigFilePath())
@@ -35,7 +41,11 @@ func newValidateConfigCmd() *cobra.Command {
// Test connection
client := api.NewClient(cfg)
if err := client.TestConnection(cmd.Context()); err != nil {
return fmt.Errorf("connection failed: %w", err)
var authErr *api.AuthError
if errors.As(err, &authErr) {
return exitcodes.Newf(exitcodes.AuthFailure, "authentication failed: %v\n Reconfigure with: onyx-cli configure", err)
}
return exitcodes.Newf(exitcodes.Unreachable, "connection failed: %v\n Reconfigure with: onyx-cli configure", err)
}
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Status: connected and authenticated")

View File

@@ -149,12 +149,12 @@ func (c *Client) TestConnection(ctx context.Context) error {
if resp2.StatusCode == 401 || resp2.StatusCode == 403 {
if isHTML || strings.Contains(respServer, "awselb") {
return fmt.Errorf("HTTP %d from a reverse proxy (not the Onyx backend).\n Check your deployment's ingress / proxy configuration", resp2.StatusCode)
return &AuthError{Message: fmt.Sprintf("HTTP %d from a reverse proxy (not the Onyx backend).\n Check your deployment's ingress / proxy configuration", resp2.StatusCode)}
}
if resp2.StatusCode == 401 {
return fmt.Errorf("invalid API key or token.\n %s", body)
return &AuthError{Message: fmt.Sprintf("invalid API key or token.\n %s", body)}
}
return fmt.Errorf("access denied — check that the API key is valid.\n %s", body)
return &AuthError{Message: fmt.Sprintf("access denied — check that the API key is valid.\n %s", body)}
}
detail := fmt.Sprintf("HTTP %d", resp2.StatusCode)

View File

@@ -11,3 +11,12 @@ type OnyxAPIError struct {
func (e *OnyxAPIError) Error() string {
return fmt.Sprintf("HTTP %d: %s", e.StatusCode, e.Detail)
}
// AuthError is returned when authentication or authorization fails.
type AuthError struct {
Message string
}
func (e *AuthError) Error() string {
return e.Message
}

View File

@@ -59,8 +59,10 @@ func ConfigExists() bool {
return err == nil
}
// Load reads config from file and applies environment variable overrides.
func Load() OnyxCliConfig {
// LoadFromDisk reads config from the file only, without applying environment
// variable overrides. Use this when you need the persisted config values
// (e.g., to preserve them during a save operation).
func LoadFromDisk() OnyxCliConfig {
cfg := DefaultConfig()
data, err := os.ReadFile(ConfigFilePath())
@@ -70,6 +72,13 @@ func Load() OnyxCliConfig {
}
}
return cfg
}
// Load reads config from file and applies environment variable overrides.
func Load() OnyxCliConfig {
cfg := LoadFromDisk()
// Environment overrides
if v := os.Getenv(EnvServerURL); v != "" {
cfg.ServerURL = v

View File

@@ -0,0 +1,187 @@
---
name: onyx-cli
description: Query the Onyx knowledge base using the onyx-cli command. Use when the user wants to search company documents, ask questions about internal knowledge, query connected data sources, or look up information stored in Onyx.
---
# Onyx CLI — Agent Tool
Onyx is an enterprise search and Gen-AI platform that connects to company documents, apps, and people. The `onyx-cli` CLI provides non-interactive commands to query the Onyx knowledge base and list available agents.
## Prerequisites
### 1. Check if installed
```bash
which onyx-cli
```
### 2. Install (if needed)
**Primary — pip:**
```bash
pip install onyx-cli
```
**From source (Go):**
```bash
go build -o onyx-cli github.com/onyx-dot-app/onyx/cli && sudo mv onyx-cli /usr/local/bin/
```
### 3. Check if configured
```bash
onyx-cli validate-config
```
This checks the config file exists, API key is present, and tests the server connection via `/api/me`. Exit code 0 on success, non-zero with a descriptive error on failure.
If unconfigured, you have two options:
**Option A — Interactive setup (requires user input):**
```bash
onyx-cli configure
```
This prompts for the Onyx server URL and API key, tests the connection, and saves config.
**Option B — Environment variables (non-interactive, preferred for agents):**
```bash
export ONYX_SERVER_URL="https://your-onyx-server.com" # default: https://cloud.onyx.app
export ONYX_API_KEY="your-api-key"
```
Environment variables override the config file. If these are set, no config file is needed.
| Variable | Required | Description |
| ----------------- | -------- | -------------------------------------------------------- |
| `ONYX_SERVER_URL` | No | Onyx server base URL (default: `https://cloud.onyx.app`) |
| `ONYX_API_KEY` | Yes | API key for authentication |
| `ONYX_PERSONA_ID` | No | Default agent/persona ID |
If neither the config file nor environment variables are set, tell the user that `onyx-cli` needs to be configured and ask them to either:
- Run `onyx-cli configure` interactively, or
- Set `ONYX_SERVER_URL` and `ONYX_API_KEY` environment variables
## Commands
### Validate configuration
```bash
onyx-cli validate-config
```
Checks config file exists, API key is present, and tests the server connection. Use this before `ask` or `agents` to confirm the CLI is properly set up.
### List available agents
```bash
onyx-cli agents
```
Prints a table of agent IDs, names, and descriptions. Use `--json` for structured output:
```bash
onyx-cli agents --json
```
Use agent IDs with `ask --agent-id` to query a specific agent.
### Basic query (plain text output)
```bash
onyx-cli ask "What is our company's PTO policy?"
```
Streams the answer as plain text to stdout. Exit code 0 on success, non-zero on error.
### JSON output (structured events)
```bash
onyx-cli ask --json "What authentication methods do we support?"
```
Outputs JSON-encoded parsed stream events (one object per line). Key event objects include message deltas, stop, errors, search-start, and citation payloads.
Each line is a JSON object with this envelope:
```json
{"type": "<event_type>", "event": { ... }}
```
| Event Type | Description |
| ------------------- | -------------------------------------------------------------------- |
| `message_delta` | Content token — concatenate all `content` fields for the full answer |
| `stop` | Stream complete |
| `error` | Error with `error` message field |
| `search_tool_start` | Onyx started searching documents |
| `citation_info` | Source citation — see shape below |
`citation_info` event shape:
```json
{
"type": "citation_info",
"event": {
"citation_number": 1,
"document_id": "abc123def456",
"placement": { "turn_index": 0, "tab_index": 0, "sub_turn_index": null }
}
}
```
`placement` is metadata about where in the conversation the citation appeared and can be ignored for most use cases.
### Specify an agent
```bash
onyx-cli ask --agent-id 5 "Summarize our Q4 roadmap"
```
Uses a specific Onyx agent/persona instead of the default.
### All flags
| Flag | Type | Description |
| ------------ | ---- | ---------------------------------------------- |
| `--agent-id` | int | Agent ID to use (overrides default) |
| `--json` | bool | Output raw NDJSON events instead of plain text |
## Statelessness
Each `onyx-cli ask` call creates an independent chat session. There is no built-in way to chain context across multiple `ask` invocations — every call starts fresh. If you need multi-turn conversation with memory, use the interactive TUI (`onyx-cli` or `onyx-cli chat`) instead.
## When to Use
Use `onyx-cli ask` when:
- The user asks about company-specific information (policies, docs, processes)
- You need to search internal knowledge bases or connected data sources
- The user references Onyx, asks you to "search Onyx", or wants to query their documents
- You need context from company wikis, Confluence, Google Drive, Slack, or other connected sources
Do NOT use when:
- The question is about general programming knowledge (use your own knowledge)
- The user is asking about code in the current repository (use grep/read tools)
- The user hasn't mentioned Onyx and the question doesn't require internal company data
## Examples
```bash
# Simple question
onyx-cli ask "What are the steps to deploy to production?"
# Get structured output for parsing
onyx-cli ask --json "List all active API integrations"
# Use a specialized agent
onyx-cli ask --agent-id 3 "What were the action items from last week's standup?"
# Pipe the answer into another command
onyx-cli ask "What is the database schema for users?" | head -20
```

View File

@@ -0,0 +1,7 @@
// Package embedded holds files that are compiled into the onyx-cli binary.
package embedded
import _ "embed"
//go:embed SKILL.md
var SkillMD string

View File

@@ -0,0 +1,33 @@
// Package exitcodes defines semantic exit codes for the Onyx CLI.
package exitcodes
import "fmt"
const (
Success = 0
General = 1
BadRequest = 2 // invalid args / command-line errors (convention)
NotConfigured = 3
AuthFailure = 4
Unreachable = 5
)
// ExitError wraps an error with a specific exit code.
type ExitError struct {
Code int
Err error
}
func (e *ExitError) Error() string {
return e.Err.Error()
}
// New creates an ExitError with the given code and message.
func New(code int, msg string) *ExitError {
return &ExitError{Code: code, Err: fmt.Errorf("%s", msg)}
}
// Newf creates an ExitError with a formatted message.
func Newf(code int, format string, args ...any) *ExitError {
return &ExitError{Code: code, Err: fmt.Errorf(format, args...)}
}

View File

@@ -0,0 +1,40 @@
package exitcodes
import (
"errors"
"fmt"
"testing"
)
func TestExitError_Error(t *testing.T) {
e := New(NotConfigured, "not configured")
if e.Error() != "not configured" {
t.Fatalf("expected 'not configured', got %q", e.Error())
}
if e.Code != NotConfigured {
t.Fatalf("expected code %d, got %d", NotConfigured, e.Code)
}
}
func TestExitError_Newf(t *testing.T) {
e := Newf(Unreachable, "cannot reach %s", "server")
if e.Error() != "cannot reach server" {
t.Fatalf("expected 'cannot reach server', got %q", e.Error())
}
if e.Code != Unreachable {
t.Fatalf("expected code %d, got %d", Unreachable, e.Code)
}
}
func TestExitError_ErrorsAs(t *testing.T) {
e := New(BadRequest, "bad input")
wrapped := fmt.Errorf("wrapper: %w", e)
var exitErr *ExitError
if !errors.As(wrapped, &exitErr) {
t.Fatal("errors.As should find ExitError")
}
if exitErr.Code != BadRequest {
t.Fatalf("expected code %d, got %d", BadRequest, exitErr.Code)
}
}

View File

@@ -0,0 +1,50 @@
// Package fsutil provides filesystem helper functions.
package fsutil
import (
"bytes"
"errors"
"fmt"
"os"
)
// FileStatus describes how an on-disk file compares to expected content.
type FileStatus int
const (
StatusMissing FileStatus = iota
StatusUpToDate // file exists with identical content
StatusDiffers // file exists with different content
)
// CompareFile checks whether the file at path matches the expected content.
func CompareFile(path string, expected []byte) (FileStatus, error) {
existing, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return StatusMissing, nil
}
return 0, fmt.Errorf("could not read %s: %w", path, err)
}
if bytes.Equal(existing, expected) {
return StatusUpToDate, nil
}
return StatusDiffers, nil
}
// EnsureDirForCopy makes sure path is a real directory, not a symlink or
// regular file. If a symlink or file exists at path it is removed so the
// caller can create a directory with independent content.
func EnsureDirForCopy(path string) error {
info, err := os.Lstat(path)
if err == nil {
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
if err := os.Remove(path); err != nil {
return err
}
}
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
return nil
}

View File

@@ -0,0 +1,116 @@
package fsutil
import (
"os"
"path/filepath"
"testing"
)
// TestCompareFile verifies that CompareFile correctly distinguishes between a
// missing file, a file with matching content, and a file with different content.
func TestCompareFile(t *testing.T) {
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "skill.md")
expected := []byte("expected content")
status, err := CompareFile(path, expected)
if err != nil {
t.Fatalf("CompareFile on missing file failed: %v", err)
}
if status != StatusMissing {
t.Fatalf("expected StatusMissing, got %v", status)
}
if err := os.WriteFile(path, expected, 0o644); err != nil {
t.Fatalf("write expected file failed: %v", err)
}
status, err = CompareFile(path, expected)
if err != nil {
t.Fatalf("CompareFile on matching file failed: %v", err)
}
if status != StatusUpToDate {
t.Fatalf("expected StatusUpToDate, got %v", status)
}
if err := os.WriteFile(path, []byte("different content"), 0o644); err != nil {
t.Fatalf("write different file failed: %v", err)
}
status, err = CompareFile(path, expected)
if err != nil {
t.Fatalf("CompareFile on different file failed: %v", err)
}
if status != StatusDiffers {
t.Fatalf("expected StatusDiffers, got %v", status)
}
}
// TestEnsureDirForCopy verifies that EnsureDirForCopy clears symlinks and
// regular files so --copy can write a real directory, while leaving existing
// directories and missing paths untouched.
func TestEnsureDirForCopy(t *testing.T) {
t.Run("removes symlink", func(t *testing.T) {
tmpDir := t.TempDir()
targetDir := filepath.Join(tmpDir, "target")
linkPath := filepath.Join(tmpDir, "link")
if err := os.MkdirAll(targetDir, 0o755); err != nil {
t.Fatalf("mkdir target failed: %v", err)
}
if err := os.Symlink(targetDir, linkPath); err != nil {
t.Fatalf("create symlink failed: %v", err)
}
if err := EnsureDirForCopy(linkPath); err != nil {
t.Fatalf("EnsureDirForCopy failed: %v", err)
}
if _, err := os.Lstat(linkPath); !os.IsNotExist(err) {
t.Fatalf("expected symlink path to be removed, got err=%v", err)
}
})
t.Run("removes regular file", func(t *testing.T) {
tmpDir := t.TempDir()
filePath := filepath.Join(tmpDir, "onyx-cli")
if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil {
t.Fatalf("write file failed: %v", err)
}
if err := EnsureDirForCopy(filePath); err != nil {
t.Fatalf("EnsureDirForCopy failed: %v", err)
}
if _, err := os.Lstat(filePath); !os.IsNotExist(err) {
t.Fatalf("expected file path to be removed, got err=%v", err)
}
})
t.Run("keeps existing directory", func(t *testing.T) {
tmpDir := t.TempDir()
dirPath := filepath.Join(tmpDir, "onyx-cli")
if err := os.MkdirAll(dirPath, 0o755); err != nil {
t.Fatalf("mkdir failed: %v", err)
}
if err := EnsureDirForCopy(dirPath); err != nil {
t.Fatalf("EnsureDirForCopy failed: %v", err)
}
info, err := os.Lstat(dirPath)
if err != nil {
t.Fatalf("lstat directory failed: %v", err)
}
if !info.IsDir() {
t.Fatalf("expected directory to remain, got mode %v", info.Mode())
}
})
t.Run("missing path is no-op", func(t *testing.T) {
tmpDir := t.TempDir()
missingPath := filepath.Join(tmpDir, "does-not-exist")
if err := EnsureDirForCopy(missingPath); err != nil {
t.Fatalf("EnsureDirForCopy failed: %v", err)
}
})
}

View File

@@ -0,0 +1,121 @@
// Package overflow provides a streaming writer that auto-truncates output
// for non-TTY callers (e.g., AI agents, scripts). Full content is saved to
// a temp file on disk; only the first N bytes are printed to stdout.
package overflow
import (
"fmt"
"os"
"strings"
log "github.com/sirupsen/logrus"
)
// Writer handles streaming output with optional truncation.
// When Limit > 0, it streams to a temp file on disk (not memory) and stops
// writing to stdout after Limit bytes. When Limit == 0, it writes directly
// to stdout. In Quiet mode, it buffers in memory and prints once at the end.
type Writer struct {
Limit int
Quiet bool
written int
totalBytes int
truncated bool
buf strings.Builder // used only in quiet mode
tmpFile *os.File // used only in truncation mode (Limit > 0)
}
// Write sends a chunk of content through the writer.
func (w *Writer) Write(s string) {
w.totalBytes += len(s)
// Quiet mode: buffer in memory, print nothing
if w.Quiet {
w.buf.WriteString(s)
return
}
if w.Limit <= 0 {
fmt.Print(s)
return
}
// Truncation mode: stream all content to temp file on disk
if w.tmpFile == nil {
f, err := os.CreateTemp("", "onyx-ask-*.txt")
if err != nil {
// Fall back to no-truncation if we can't create the file
fmt.Fprintf(os.Stderr, "warning: could not create temp file: %v\n", err)
w.Limit = 0
fmt.Print(s)
return
}
w.tmpFile = f
}
if _, err := w.tmpFile.WriteString(s); err != nil {
// Disk write failed — abandon truncation, stream directly to stdout
fmt.Fprintf(os.Stderr, "warning: temp file write failed: %v\n", err)
w.closeTmpFile(true)
w.Limit = 0
w.truncated = false
fmt.Print(s)
return
}
if w.truncated {
return
}
remaining := w.Limit - w.written
if len(s) <= remaining {
fmt.Print(s)
w.written += len(s)
} else {
if remaining > 0 {
fmt.Print(s[:remaining])
w.written += remaining
}
w.truncated = true
}
}
// Finish flushes remaining output. Call once after all Write calls are done.
func (w *Writer) Finish() {
// Quiet mode: print buffered content at once
if w.Quiet {
fmt.Println(w.buf.String())
return
}
if !w.truncated {
w.closeTmpFile(true) // clean up unused temp file
fmt.Println()
return
}
// Close the temp file so it's readable
tmpPath := w.tmpFile.Name()
w.closeTmpFile(false) // close but keep the file
fmt.Printf("\n\n--- response truncated (%d bytes total) ---\n", w.totalBytes)
fmt.Printf("Full response: %s\n", tmpPath)
fmt.Printf("Explore:\n")
fmt.Printf(" cat %s | grep \"<pattern>\"\n", tmpPath)
fmt.Printf(" cat %s | tail -50\n", tmpPath)
}
// closeTmpFile closes and optionally removes the temp file.
func (w *Writer) closeTmpFile(remove bool) {
if w.tmpFile == nil {
return
}
if err := w.tmpFile.Close(); err != nil {
log.Debugf("warning: failed to close temp file: %v", err)
}
if remove {
if err := os.Remove(w.tmpFile.Name()); err != nil {
log.Debugf("warning: failed to remove temp file: %v", err)
}
}
w.tmpFile = nil
}

View File

@@ -0,0 +1,95 @@
package overflow
import (
"os"
"testing"
)
func TestWriter_NoLimit(t *testing.T) {
w := &Writer{Limit: 0}
w.Write("hello world")
if w.truncated {
t.Fatal("should not be truncated with limit 0")
}
if w.totalBytes != 11 {
t.Fatalf("expected 11 total bytes, got %d", w.totalBytes)
}
}
func TestWriter_UnderLimit(t *testing.T) {
w := &Writer{Limit: 100}
w.Write("hello")
w.Write(" world")
if w.truncated {
t.Fatal("should not be truncated when under limit")
}
if w.written != 11 {
t.Fatalf("expected 11 written bytes, got %d", w.written)
}
}
func TestWriter_OverLimit(t *testing.T) {
w := &Writer{Limit: 5}
w.Write("hello world") // 11 bytes, limit 5
if !w.truncated {
t.Fatal("should be truncated")
}
if w.written != 5 {
t.Fatalf("expected 5 written bytes, got %d", w.written)
}
if w.totalBytes != 11 {
t.Fatalf("expected 11 total bytes, got %d", w.totalBytes)
}
if w.tmpFile == nil {
t.Fatal("temp file should have been created")
}
_ = w.tmpFile.Close()
data, _ := os.ReadFile(w.tmpFile.Name())
_ = os.Remove(w.tmpFile.Name())
if string(data) != "hello world" {
t.Fatalf("temp file should contain full content, got %q", string(data))
}
}
func TestWriter_MultipleChunks(t *testing.T) {
w := &Writer{Limit: 10}
w.Write("hello") // 5 bytes
w.Write(" ") // 6 bytes
w.Write("world") // 11 bytes, crosses limit
w.Write("!") // 12 bytes, already truncated
if !w.truncated {
t.Fatal("should be truncated")
}
if w.written != 10 {
t.Fatalf("expected 10 written bytes, got %d", w.written)
}
if w.totalBytes != 12 {
t.Fatalf("expected 12 total bytes, got %d", w.totalBytes)
}
if w.tmpFile == nil {
t.Fatal("temp file should have been created")
}
_ = w.tmpFile.Close()
data, _ := os.ReadFile(w.tmpFile.Name())
_ = os.Remove(w.tmpFile.Name())
if string(data) != "hello world!" {
t.Fatalf("temp file should contain full content, got %q", string(data))
}
}
func TestWriter_QuietMode(t *testing.T) {
w := &Writer{Limit: 0, Quiet: true}
w.Write("hello")
w.Write(" world")
if w.written != 0 {
t.Fatalf("quiet mode should not write to stdout, got %d written", w.written)
}
if w.totalBytes != 11 {
t.Fatalf("expected 11 total bytes, got %d", w.totalBytes)
}
if w.buf.String() != "hello world" {
t.Fatalf("buffer should contain full content, got %q", w.buf.String())
}
}

View File

@@ -1,10 +1,12 @@
package main
import (
"errors"
"fmt"
"os"
"github.com/onyx-dot-app/onyx/cli/cmd"
"github.com/onyx-dot-app/onyx/cli/internal/exitcodes"
)
var (
@@ -18,6 +20,10 @@ func main() {
if err := cmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
var exitErr *exitcodes.ExitError
if errors.As(err, &exitErr) {
os.Exit(exitErr.Code)
}
os.Exit(1)
}
}

View File

@@ -127,7 +127,7 @@ function SidebarTab({
rightChildren={truncationSpacer}
/>
) : (
<div className="flex flex-row items-center gap-2 flex-1">
<div className="flex flex-row items-center gap-2 w-full">
{Icon && (
<div className="flex items-center justify-center p-0.5">
<Icon className="h-[1rem] w-[1rem] text-text-03" />
@@ -153,7 +153,7 @@ function SidebarTab({
side="right"
sideOffset={4}
>
<Text>{children}</Text>
{children}
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
</TooltipPrimitive.Root>

View File

@@ -467,6 +467,10 @@
/* Frost Overlay (for FrostedDiv component) - lighter in light mode */
--frost-overlay: var(--alpha-grey-00-10);
/* Scrollbar */
--scrollbar-track: transparent;
--scrollbar-thumb: var(--alpha-grey-100-20);
}
/* Dark Colors */
@@ -671,4 +675,8 @@
/* Frost Overlay (for FrostedDiv component) - darker in dark mode */
--frost-overlay: var(--alpha-grey-100-10);
/* Scrollbar */
--scrollbar-track: transparent;
--scrollbar-thumb: var(--alpha-grey-00-20);
}

View File

@@ -127,17 +127,8 @@
}
@layer utilities {
/* Hide scrollbar for Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.no-scrollbar {
-ms-overflow-style: none;
/* IE and Edge */
scrollbar-width: none;
/* Firefox */
}
/* SHADOWS */
@@ -362,27 +353,9 @@
/* SCROLL BAR */
.default-scrollbar::-webkit-scrollbar {
width: 6px;
}
.default-scrollbar::-webkit-scrollbar-track {
background: #f1f1f1;
}
.default-scrollbar::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
.default-scrollbar::-webkit-scrollbar-thumb:hover {
background: #555;
}
.default-scrollbar {
scrollbar-width: thin;
scrollbar-color: #888 transparent;
overflow: overlay;
overflow-y: scroll;
overflow-x: hidden;
}
@@ -392,78 +365,21 @@
height: 100%;
}
.inputscroll::-webkit-scrollbar-track {
background: #e5e7eb;
.inputscroll {
scrollbar-width: none;
}
::-webkit-scrollbar {
width: 0px;
/* Vertical scrollbar width */
height: 8px;
/* Horizontal scrollbar height */
}
::-webkit-scrollbar-track {
background: transparent;
/* background: theme("colors.scrollbar.track"); */
/* Track background color */
}
/* Style the scrollbar handle */
::-webkit-scrollbar-thumb {
background: transparent;
/* background: theme("colors.scrollbar.thumb"); */
/* Handle color */
border-radius: 10px;
}
/* Handle on hover */
::-webkit-scrollbar-thumb:hover {
background: transparent;
/* background: theme("colors.scrollbar.thumb-hover"); */
/* Handle color on hover */
}
.dark-scrollbar::-webkit-scrollbar-thumb {
background: transparent;
/* background: theme("colors.scrollbar.dark.thumb"); */
/* Handle color */
border-radius: 10px;
}
.dark-scrollbar::-webkit-scrollbar-thumb:hover {
background: transparent;
/* background: theme("colors.scrollbar.dark.thumb-hover"); */
/* Handle color on hover */
/* Ensure native scrollbars are visible */
@layer base {
* {
scrollbar-width: auto;
}
}
/* TEXTAREA */
textarea::-webkit-scrollbar {
width: 8px;
}
textarea::-webkit-scrollbar-track {
background: var(--scrollbar-track);
border-radius: 4px;
}
textarea::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 4px;
}
textarea::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover);
}
textarea {
resize: vertical;
}
/* For Firefox */
textarea {
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
}

View File

@@ -5,6 +5,7 @@ import { SvgDownload, SvgTextLines } from "@opal/icons";
import Modal from "@/refresh-components/Modal";
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
import CopyIconButton from "@/refresh-components/buttons/CopyIconButton";
import { Hoverable } from "@opal/core";
import { useHookExecutionLogs } from "@/ee/hooks/useHookExecutionLogs";
import { formatDateTimeLog } from "@/lib/dateUtils";
import { downloadFile } from "@/lib/download";
@@ -40,33 +41,40 @@ function SectionHeader({ label }: { label: string }) {
);
}
function LogRow({ log }: { log: HookExecutionRecord }) {
function LogRow({ log, group }: { log: HookExecutionRecord; group: string }) {
return (
<Section
flexDirection="row"
justifyContent="start"
alignItems="start"
gap={0.5}
height="fit"
className="py-2"
>
{/* 1. Timestamp */}
<span className="shrink-0 text-code-code">
<Text font="secondary-mono-label" color="inherit" nowrap>
{formatDateTimeLog(log.created_at)}
</Text>
</span>
{/* 2. Error message */}
<span className="flex-1 min-w-0 break-all whitespace-pre-wrap text-code-code">
<Text font="secondary-mono" color="inherit">
{log.error_message ?? "Unknown error"}
</Text>
</span>
{/* 3. Copy button */}
<Section width="fit" height="fit" alignItems="center">
<CopyIconButton size="xs" getCopyText={() => log.error_message ?? ""} />
<Hoverable.Root group={group}>
<Section
flexDirection="row"
justifyContent="start"
alignItems="start"
gap={0.5}
height="fit"
className="py-2"
>
{/* 1. Timestamp */}
<span className="shrink-0 text-code-code">
<Text font="secondary-mono-label" color="inherit" nowrap>
{formatDateTimeLog(log.created_at)}
</Text>
</span>
{/* 2. Error message */}
<span className="flex-1 min-w-0 break-all whitespace-pre-wrap text-code-code">
<Text font="secondary-mono" color="inherit">
{log.error_message ?? "Unknown error"}
</Text>
</span>
{/* 3. Copy button */}
<Section width="fit" height="fit" alignItems="center">
<Hoverable.Item group={group} variant="opacity-on-hover">
<CopyIconButton
size="xs"
getCopyText={() => log.error_message ?? ""}
/>
</Hoverable.Item>
</Section>
</Section>
</Section>
</Hoverable.Root>
);
}
@@ -126,7 +134,11 @@ export default function HookLogsModal({ hook, spec }: HookLogsModalProps) {
<>
<SectionHeader label="Past Hour" />
{recentErrors.map((log, idx) => (
<LogRow key={log.created_at + String(idx)} log={log} />
<LogRow
key={log.created_at + String(idx)}
log={log}
group={log.created_at + String(idx)}
/>
))}
</>
)}
@@ -134,7 +146,11 @@ export default function HookLogsModal({ hook, spec }: HookLogsModalProps) {
<>
<SectionHeader label="Older" />
{olderErrors.map((log, idx) => (
<LogRow key={log.created_at + String(idx)} log={log} />
<LogRow
key={log.created_at + String(idx)}
log={log}
group={log.created_at + String(idx)}
/>
))}
</>
)}

View File

@@ -3,7 +3,7 @@
import { useEffect, useRef, useState } from "react";
import { useCreateModal } from "@/refresh-components/contexts/ModalContext";
import { noProp } from "@/lib/utils";
import { formatTimeOnly } from "@/lib/dateUtils";
import { formatDateTimeLog } from "@/lib/dateUtils";
import { Button, Text } from "@opal/components";
import { Content } from "@opal/layouts";
import LineItem from "@/refresh-components/buttons/LineItem";
@@ -18,6 +18,7 @@ import {
SvgXOctagon,
} from "@opal/icons";
import CopyIconButton from "@/refresh-components/buttons/CopyIconButton";
import { Hoverable } from "@opal/core";
import { useHookExecutionLogs } from "@/ee/hooks/useHookExecutionLogs";
import HookLogsModal from "@/ee/refresh-pages/admin/HooksPage/HookLogsModal";
import type {
@@ -26,6 +27,52 @@ import type {
} from "@/ee/refresh-pages/admin/HooksPage/interfaces";
import { cn } from "@opal/utils";
function ErrorLogRow({
log,
group,
}: {
log: { created_at: string; error_message: string | null };
group: string;
}) {
return (
<Hoverable.Root group={group}>
<Section
flexDirection="column"
justifyContent="start"
alignItems="start"
gap={0.25}
padding={0.25}
height="fit"
>
<Section
flexDirection="row"
justifyContent="between"
alignItems="center"
gap={0}
height="fit"
>
<span className="text-code-code">
<Text font="secondary-mono-label" color="inherit">
{formatDateTimeLog(log.created_at)}
</Text>
</span>
<Hoverable.Item group={group} variant="opacity-on-hover">
<CopyIconButton
size="xs"
getCopyText={() => log.error_message ?? ""}
/>
</Hoverable.Item>
</Section>
<span className="break-all">
<Text font="secondary-mono" color="text-03">
{log.error_message ?? "Unknown error"}
</Text>
</span>
</Section>
</Hoverable.Root>
);
}
interface HookStatusPopoverProps {
hook: HookResponse;
spec: HookPointMeta | undefined;
@@ -43,9 +90,16 @@ export default function HookStatusPopover({
const [clickOpened, setClickOpened] = useState(false);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const { hasRecentErrors, recentErrors, isLoading, error } =
const { hasRecentErrors, recentErrors, olderErrors, isLoading, error } =
useHookExecutionLogs(hook.id);
const topErrors = [...recentErrors, ...olderErrors]
.sort(
(a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
)
.slice(0, 3);
useEffect(() => {
return () => {
if (closeTimerRef.current) clearTimeout(closeTimerRef.current);
@@ -162,7 +216,15 @@ export default function HookStatusPopover({
justifyContent="start"
alignItems="start"
height="fit"
width={hasRecentErrors ? 20 : 12.5}
width={
hook.is_reachable === false
? topErrors.length > 0
? 20
: 12.5
: hasRecentErrors
? 20
: 12.5
}
padding={0.125}
gap={0.25}
>
@@ -174,13 +236,70 @@ export default function HookStatusPopover({
<Text font="secondary-body" color="text-03">
Failed to load logs.
</Text>
) : hook.is_reachable === false ? (
<>
<div className="p-1">
<Content
sizePreset="secondary"
variant="section"
icon={(props) => (
<SvgXOctagon
{...props}
className="text-status-error-05"
/>
)}
title="Most Recent Errors"
/>
</div>
{topErrors.length > 0 ? (
<>
<Separator noPadding className="px-2" />
<Section
flexDirection="column"
justifyContent="start"
alignItems="start"
gap={0.25}
padding={0.25}
height="fit"
>
{topErrors.map((log, idx) => (
<ErrorLogRow
key={log.created_at + String(idx)}
log={log}
group={log.created_at + String(idx)}
/>
))}
</Section>
</>
) : (
<Separator noPadding className="px-2" />
)}
<LineItem
muted
icon={SvgMaximize2}
onClick={noProp(() => {
handleOpenChange(false);
logsModal.toggle(true);
})}
>
View More Lines
</LineItem>
</>
) : hasRecentErrors ? (
<>
<div className="p-1">
<Content
sizePreset="secondary"
variant="section"
icon={SvgXOctagon}
icon={(props) => (
<SvgXOctagon
{...props}
className="text-status-error-05"
/>
)}
title={
recentErrors.length <= 3
? `${recentErrors.length} ${
@@ -204,38 +323,11 @@ export default function HookStatusPopover({
height="fit"
>
{recentErrors.slice(0, 3).map((log, idx) => (
<Section
<ErrorLogRow
key={log.created_at + String(idx)}
flexDirection="column"
justifyContent="start"
alignItems="start"
gap={0.25}
padding={0.25}
height="fit"
>
<Section
flexDirection="row"
justifyContent="between"
alignItems="center"
gap={0}
height="fit"
>
<span className="text-code-code">
<Text font="secondary-mono-label" color="inherit">
{formatTimeOnly(log.created_at)}
</Text>
</span>
<CopyIconButton
size="xs"
getCopyText={() => log.error_message ?? ""}
/>
</Section>
<span className="break-all">
<Text font="secondary-mono" color="text-03">
{log.error_message ?? "Unknown error"}
</Text>
</span>
</Section>
log={log}
group={log.created_at + String(idx)}
/>
))}
</Section>

View File

@@ -41,6 +41,7 @@ import {
activateHook,
deactivateHook,
deleteHook,
getHook,
validateHook,
} from "@/ee/refresh-pages/admin/HooksPage/svc";
import type {
@@ -319,9 +320,16 @@ function ConnectedHookCard({
toast.error(
err instanceof Error ? err.message : "Failed to validate hook."
);
return;
} finally {
setIsBusy(false);
}
try {
const updated = await getHook(hook.id);
onToggled(updated);
} catch (err) {
console.error("Failed to refresh hook after validation:", err);
}
}
const HookIcon = getHookPointIcon(hook.hook_point);

View File

@@ -87,6 +87,14 @@ export async function deactivateHook(id: number): Promise<HookResponse> {
return res.json();
}
export async function getHook(id: number): Promise<HookResponse> {
const res = await fetch(`/api/admin/hooks/${id}`);
if (!res.ok) {
throw await parseError(res, "Failed to fetch hook");
}
return res.json();
}
export async function validateHook(id: number): Promise<HookValidateResponse> {
const res = await fetch(`/api/admin/hooks/${id}/validate`, {
method: "POST",

View File

@@ -14,7 +14,7 @@ import { CombinedSettings } from "@/interfaces/settings";
import { SidebarTab } from "@opal/components";
import SidebarBody from "@/sections/sidebar/SidebarBody";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import { Disabled } from "@opal/core";
import Separator from "@/refresh-components/Separator";
import { SvgArrowUpCircle, SvgUserManage, SvgX } from "@opal/icons";
import {
useBillingInformation,
@@ -141,15 +141,12 @@ function buildItems(
if (!isCurator) {
if (hasSubscription) {
add(SECTIONS.ORGANIZATION, ADMIN_ROUTES.BILLING);
} else {
items.push({
section: SECTIONS.ORGANIZATION,
name: "Upgrade Plan",
icon: SvgArrowUpCircle,
link: ADMIN_ROUTES.BILLING.path,
});
}
add(SECTIONS.ORGANIZATION, ADMIN_ROUTES.TOKEN_RATE_LIMITS);
addDisabled(
SECTIONS.ORGANIZATION,
ADMIN_ROUTES.TOKEN_RATE_LIMITS,
!enableEnterprise
);
addDisabled(SECTIONS.ORGANIZATION, ADMIN_ROUTES.THEME, !enableEnterprise);
}
@@ -165,6 +162,16 @@ function buildItems(
}
}
// 8. Upgrade Plan (admin only, no subscription)
if (!isCurator && !hasSubscription) {
items.push({
section: SECTIONS.UNLABELED,
name: "Upgrade Plan",
icon: SvgArrowUpCircle,
link: ADMIN_ROUTES.BILLING.path,
});
}
return items;
}
@@ -224,7 +231,10 @@ export default function AdminSidebar({ enableCloudSS }: AdminSidebarProps) {
const { query, setQuery, filtered } = useFilter(allItems, itemExtractor);
const groups = groupBySection(filtered);
const enabled = filtered.filter((item) => !item.disabled);
const disabled = filtered.filter((item) => item.disabled);
const enabledGroups = groupBySection(enabled);
const disabledGroups = groupBySection(disabled);
return (
<SidebarWrapper>
@@ -286,26 +296,16 @@ export default function AdminSidebar({ enableCloudSS }: AdminSidebarProps) {
</Section>
}
>
{groups.map((group, groupIndex) => {
const tabs = group.items.map(({ link, icon, name, disabled }) => (
<Disabled key={link} disabled={disabled}>
{/*
# NOTE (@raunakab)
We intentionally add a `div` intermediary here.
Without it, the disabled styling that is default provided by the `Disabled` component (which we want here) would be overridden by the custom disabled styling provided by the `SidebarTab`.
Therefore, in order to avoid that overriding, we add a layer of indirection.
*/}
<div>
<SidebarTab
disabled={disabled}
icon={icon}
href={disabled ? undefined : link}
selected={pathname.startsWith(link)}
>
{name}
</SidebarTab>
</div>
</Disabled>
{enabledGroups.map((group, groupIndex) => {
const tabs = group.items.map(({ link, icon, name }) => (
<SidebarTab
key={link}
icon={icon}
href={link}
selected={pathname.startsWith(link)}
>
{name}
</SidebarTab>
));
if (!group.section) {
@@ -318,6 +318,22 @@ export default function AdminSidebar({ enableCloudSS }: AdminSidebarProps) {
</SidebarSection>
);
})}
{disabledGroups.length > 0 && <Separator noPadding className="px-2" />}
{disabledGroups.map((group, groupIndex) => (
<SidebarSection
key={`disabled-${groupIndex}`}
title={group.section}
disabled
>
{group.items.map(({ link, icon, name }) => (
<SidebarTab key={link} disabled icon={icon}>
{name}
</SidebarTab>
))}
</SidebarSection>
))}
</SidebarBody>
</SidebarWrapper>
);

View File

@@ -2,34 +2,42 @@
import React from "react";
import Text from "@/refresh-components/texts/Text";
import { cn } from "@/lib/utils";
import { Disabled, Hoverable } from "@opal/core";
export interface SidebarSectionProps {
title: string;
children?: React.ReactNode;
action?: React.ReactNode;
className?: string;
disabled?: boolean;
}
export default function SidebarSection({
title,
children,
action,
className,
disabled,
}: SidebarSectionProps) {
return (
<div className={cn("flex flex-col group/SidebarSection", className)}>
<div className="pl-2 pr-1.5 py-1 sticky top-[0rem] bg-background-tint-02 z-10 flex flex-row items-center justify-between min-h-[2rem]">
<Text as="p" secondaryBody text02>
{title}
</Text>
{action && (
<div className="flex-shrink-0 opacity-0 group-hover/SidebarSection:opacity-100 transition-opacity">
{action}
<Hoverable.Root group="sidebar-section">
{/* Title */}
{/* NOTE: mr-1.5 is intentionally used instead of padding to avoid the background color
from overlapping with scrollbars on Safari.
*/}
<Disabled disabled={disabled}>
<div className="pl-2 mr-1.5 py-1 sticky top-0 bg-background-tint-02 z-10 flex flex-row items-center justify-between min-h-[2rem]">
<div className="p-0.5 w-full flex flex-col justify-center">
<Text secondaryBody text02>
{title}
</Text>
</div>
)}
</div>
<div>{children}</div>
</div>
{action && (
<Hoverable.Item group="sidebar-section">{action}</Hoverable.Item>
)}
</div>
</Disabled>
{/* Contents */}
{children}
</Hoverable.Root>
);
}