mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-04-05 06:52:42 +00:00
Compare commits
11 Commits
v3.1.0-clo
...
jamison/wo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b59f8cf453 | ||
|
|
456ecc7b9a | ||
|
|
fdc2bc9ee2 | ||
|
|
1c3f371549 | ||
|
|
a120add37b | ||
|
|
757e4e979b | ||
|
|
cbcdfee56e | ||
|
|
b06700314b | ||
|
|
01f573cdcb | ||
|
|
d4a96d70f3 | ||
|
|
5b000c2173 |
@@ -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
|
||||
```
|
||||
1
.cursor/skills/onyx-cli/SKILL.md
Symbolic link
1
.cursor/skills/onyx-cli/SKILL.md
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../cli/internal/embedded/SKILL.md
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} "
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
91
backend/tests/unit/onyx/file_store/test_delete_file.py
Normal file
91
backend/tests/unit/onyx/file_store/test_delete_file.py
Normal 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()
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
140
cli/cmd/ask.go
140
cli/cmd/ask.go
@@ -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\"")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
176
cli/cmd/install_skill.go
Normal 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(©Mode, "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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
187
cli/internal/embedded/SKILL.md
Normal file
187
cli/internal/embedded/SKILL.md
Normal 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
|
||||
```
|
||||
7
cli/internal/embedded/embed.go
Normal file
7
cli/internal/embedded/embed.go
Normal 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
|
||||
33
cli/internal/exitcodes/codes.go
Normal file
33
cli/internal/exitcodes/codes.go
Normal 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...)}
|
||||
}
|
||||
40
cli/internal/exitcodes/codes_test.go
Normal file
40
cli/internal/exitcodes/codes_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
50
cli/internal/fsutil/fsutil.go
Normal file
50
cli/internal/fsutil/fsutil.go
Normal 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
|
||||
}
|
||||
116
cli/internal/fsutil/fsutil_test.go
Normal file
116
cli/internal/fsutil/fsutil_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
121
cli/internal/overflow/writer.go
Normal file
121
cli/internal/overflow/writer.go
Normal 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
|
||||
}
|
||||
95
cli/internal/overflow/writer_test.go
Normal file
95
cli/internal/overflow/writer_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user