mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-10 08:32:09 +00:00
Merge branch 'main' into bb/gui
This commit is contained in:
commit
6d3ed6b20d
13 changed files with 512 additions and 38 deletions
|
|
@ -1272,12 +1272,40 @@ def _resolve_nous_runtime_api(*, force_refresh: bool = False) -> Optional[tuple[
|
|||
def _resolve_xai_oauth_for_aux() -> Optional[Tuple[str, str]]:
|
||||
"""Resolve a fresh xAI OAuth (api_key, base_url) for auxiliary clients.
|
||||
|
||||
Routes through ``hermes_cli.auth``'s runtime resolver so the auto-refresh
|
||||
path is shared with the main agent, instead of relying on whatever raw
|
||||
tokens happen to be sitting in auth.json or the credential pool. Returns
|
||||
``None`` if the user is not authenticated with xAI Grok OAuth (so
|
||||
``_resolve_auto`` Step 1 falls through to the next provider in the chain).
|
||||
Prefer the credential pool, matching the main runtime/provider status
|
||||
path. Some xAI OAuth logins live only as pool entries; falling straight
|
||||
to the singleton auth-store resolver would make auxiliary tasks such as
|
||||
compression report "no provider configured" even though ``hermes auth
|
||||
status`` shows xAI OAuth as logged in.
|
||||
|
||||
Falls back to ``hermes_cli.auth``'s singleton runtime resolver for older
|
||||
auth-store-only logins. Returns ``None`` if the user is not authenticated
|
||||
with xAI Grok OAuth.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.auth import DEFAULT_XAI_OAUTH_BASE_URL
|
||||
|
||||
pool = load_pool("xai-oauth")
|
||||
if pool and pool.has_credentials():
|
||||
entry = pool.select()
|
||||
if entry is not None:
|
||||
api_key = str(
|
||||
getattr(entry, "runtime_api_key", None)
|
||||
or getattr(entry, "access_token", "")
|
||||
or ""
|
||||
).strip()
|
||||
base_url = str(
|
||||
os.getenv("HERMES_XAI_BASE_URL", "").strip().rstrip("/")
|
||||
or os.getenv("XAI_BASE_URL", "").strip().rstrip("/")
|
||||
or getattr(entry, "runtime_base_url", None)
|
||||
or getattr(entry, "base_url", None)
|
||||
or DEFAULT_XAI_OAUTH_BASE_URL
|
||||
).strip().rstrip("/")
|
||||
if api_key and base_url:
|
||||
return api_key, base_url
|
||||
except Exception as exc:
|
||||
logger.debug("Auxiliary xAI OAuth pool credential resolution failed: %s", exc)
|
||||
|
||||
try:
|
||||
from hermes_cli.auth import resolve_xai_oauth_runtime_credentials
|
||||
|
||||
|
|
|
|||
8
cli.py
8
cli.py
|
|
@ -11736,11 +11736,13 @@ class HermesCLI:
|
|||
|
||||
# Ensure tirith security scanner is available (downloads if needed).
|
||||
# Warn the user if tirith is enabled in config but not available,
|
||||
# so they know command security scanning is degraded.
|
||||
# so they know command security scanning is degraded. Suppressed
|
||||
# on platforms where tirith ships no binary (Windows etc.) — the
|
||||
# user can't act on it and pattern-matching guards still run.
|
||||
try:
|
||||
from tools.tirith_security import ensure_installed
|
||||
from tools.tirith_security import ensure_installed, is_platform_supported
|
||||
tirith_path = ensure_installed(log_failures=False)
|
||||
if tirith_path is None:
|
||||
if tirith_path is None and is_platform_supported():
|
||||
security_cfg = self.config.get("security", {}) or {}
|
||||
tirith_enabled = security_cfg.get("tirith_enabled", True)
|
||||
if tirith_enabled:
|
||||
|
|
|
|||
19
run_agent.py
19
run_agent.py
|
|
@ -3237,11 +3237,20 @@ class AIAgent:
|
|||
except Exception:
|
||||
_aux_cfg_provider = ""
|
||||
if client is None or not aux_model:
|
||||
msg = (
|
||||
"⚠ No auxiliary LLM provider configured — context "
|
||||
"compression will drop middle turns without a summary. "
|
||||
"Run `hermes setup` or set OPENROUTER_API_KEY."
|
||||
)
|
||||
if _aux_cfg_provider and _aux_cfg_provider != "auto":
|
||||
msg = (
|
||||
"⚠ Configured auxiliary compression provider "
|
||||
f"'{_aux_cfg_provider}' is unavailable — context "
|
||||
"compression will drop middle turns without a summary. "
|
||||
"Check auxiliary.compression in config.yaml and "
|
||||
"reauthenticate that provider."
|
||||
)
|
||||
else:
|
||||
msg = (
|
||||
"⚠ No auxiliary LLM provider configured — context "
|
||||
"compression will drop middle turns without a summary. "
|
||||
"Run `hermes setup` or set OPENROUTER_API_KEY."
|
||||
)
|
||||
self._compression_warning = msg
|
||||
self._emit_status(msg)
|
||||
logger.warning(
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ from agent.auxiliary_client import (
|
|||
_normalize_aux_provider,
|
||||
_try_payment_fallback,
|
||||
_resolve_auto,
|
||||
_resolve_xai_oauth_for_aux,
|
||||
_CodexCompletionsAdapter,
|
||||
)
|
||||
|
||||
|
|
@ -221,6 +222,77 @@ class TestReadCodexAccessToken:
|
|||
assert result == "plain-token-no-jwt"
|
||||
|
||||
|
||||
class TestResolveXaiOAuthForAux:
|
||||
def test_uses_pool_backed_credentials_without_singleton(self, tmp_path, monkeypatch):
|
||||
"""Auxiliary xAI OAuth must see pool-only credentials.
|
||||
|
||||
``hermes auth status`` already reports these as logged in; compression
|
||||
should not fall through to "no auxiliary provider configured" just
|
||||
because the singleton auth-store entry is absent.
|
||||
"""
|
||||
from agent.credential_pool import AUTH_TYPE_OAUTH, PooledCredential, load_pool
|
||||
from hermes_cli.auth import DEFAULT_XAI_OAUTH_BASE_URL
|
||||
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
(hermes_home / "auth.json").write_text(json.dumps({
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
}))
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.delenv("HERMES_XAI_BASE_URL", raising=False)
|
||||
monkeypatch.delenv("XAI_BASE_URL", raising=False)
|
||||
|
||||
pool = load_pool("xai-oauth")
|
||||
pool.add_entry(PooledCredential(
|
||||
provider="xai-oauth",
|
||||
id="xai123",
|
||||
label="pool-only",
|
||||
auth_type=AUTH_TYPE_OAUTH,
|
||||
priority=0,
|
||||
source="manual:xai_pkce",
|
||||
access_token="pool-access-token",
|
||||
refresh_token="pool-refresh-token",
|
||||
base_url=DEFAULT_XAI_OAUTH_BASE_URL,
|
||||
))
|
||||
|
||||
assert _resolve_xai_oauth_for_aux() == (
|
||||
"pool-access-token",
|
||||
DEFAULT_XAI_OAUTH_BASE_URL,
|
||||
)
|
||||
|
||||
def test_pool_backed_credentials_honor_base_url_env_override(self, tmp_path, monkeypatch):
|
||||
from agent.credential_pool import AUTH_TYPE_OAUTH, PooledCredential, load_pool
|
||||
from hermes_cli.auth import DEFAULT_XAI_OAUTH_BASE_URL
|
||||
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
(hermes_home / "auth.json").write_text(json.dumps({
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
}))
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setenv("HERMES_XAI_BASE_URL", "https://example.x.ai/v1/")
|
||||
|
||||
pool = load_pool("xai-oauth")
|
||||
pool.add_entry(PooledCredential(
|
||||
provider="xai-oauth",
|
||||
id="xai456",
|
||||
label="pool-only",
|
||||
auth_type=AUTH_TYPE_OAUTH,
|
||||
priority=0,
|
||||
source="manual:xai_pkce",
|
||||
access_token="pool-access-token",
|
||||
refresh_token="pool-refresh-token",
|
||||
base_url=DEFAULT_XAI_OAUTH_BASE_URL,
|
||||
))
|
||||
|
||||
assert _resolve_xai_oauth_for_aux() == (
|
||||
"pool-access-token",
|
||||
"https://example.x.ai/v1",
|
||||
)
|
||||
|
||||
|
||||
class TestAnthropicOAuthFlag:
|
||||
"""Test that OAuth tokens get is_oauth=True in auxiliary Anthropic client."""
|
||||
|
||||
|
|
|
|||
|
|
@ -333,6 +333,103 @@ class TestEnsureInstalled:
|
|||
_tirith_mod._resolved_path = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unsupported platform (Windows etc.) — silent fast-path everywhere
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestUnsupportedPlatform:
|
||||
"""When _detect_target() returns None (no tirith binary for this OS+arch),
|
||||
the entire subsystem must stay silent: no PATH probes, no download thread,
|
||||
no disk failure marker, no spawn attempts, no CLI banner. Pattern-matching
|
||||
guards still cover the gap; tirith content scanning is just absent."""
|
||||
|
||||
def test_is_platform_supported_true_on_linux_x86_64(self):
|
||||
with patch("tools.tirith_security.platform.system", return_value="Linux"), \
|
||||
patch("tools.tirith_security.platform.machine", return_value="x86_64"):
|
||||
assert _tirith_mod.is_platform_supported() is True
|
||||
|
||||
def test_is_platform_supported_true_on_darwin_arm64(self):
|
||||
with patch("tools.tirith_security.platform.system", return_value="Darwin"), \
|
||||
patch("tools.tirith_security.platform.machine", return_value="arm64"):
|
||||
assert _tirith_mod.is_platform_supported() is True
|
||||
|
||||
def test_is_platform_supported_false_on_windows(self):
|
||||
with patch("tools.tirith_security.platform.system", return_value="Windows"), \
|
||||
patch("tools.tirith_security.platform.machine", return_value="AMD64"):
|
||||
assert _tirith_mod.is_platform_supported() is False
|
||||
|
||||
def test_is_platform_supported_false_on_unknown_arch(self):
|
||||
with patch("tools.tirith_security.platform.system", return_value="Linux"), \
|
||||
patch("tools.tirith_security.platform.machine", return_value="riscv64"):
|
||||
assert _tirith_mod.is_platform_supported() is False
|
||||
|
||||
@patch("tools.tirith_security._load_security_config")
|
||||
def test_ensure_installed_unsupported_returns_none_no_thread(self, mock_cfg):
|
||||
"""Windows: don't start a background install thread, don't write a
|
||||
failure marker — just cache the verdict and return None."""
|
||||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||||
_tirith_mod._resolved_path = None
|
||||
with patch("tools.tirith_security.is_platform_supported", return_value=False), \
|
||||
patch("tools.tirith_security.threading.Thread") as MockThread, \
|
||||
patch("tools.tirith_security._mark_install_failed") as mock_mark, \
|
||||
patch("tools.tirith_security.shutil.which") as mock_which:
|
||||
result = ensure_installed()
|
||||
assert result is None
|
||||
MockThread.assert_not_called()
|
||||
mock_mark.assert_not_called()
|
||||
mock_which.assert_not_called()
|
||||
assert _tirith_mod._resolved_path is _tirith_mod._INSTALL_FAILED
|
||||
assert _tirith_mod._install_failure_reason == "unsupported_platform"
|
||||
|
||||
@patch("tools.tirith_security._load_security_config")
|
||||
def test_check_command_security_unsupported_allows_silently(self, mock_cfg):
|
||||
"""Windows: skip the resolver and spawn entirely — return allow with
|
||||
an empty summary so callers can't accidentally surface 'tirith
|
||||
unavailable' messaging to the user."""
|
||||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||||
with patch("tools.tirith_security.is_platform_supported", return_value=False), \
|
||||
patch("tools.tirith_security.subprocess.run") as mock_run, \
|
||||
patch("tools.tirith_security._resolve_tirith_path") as mock_resolve:
|
||||
result = check_command_security("rm -rf /")
|
||||
assert result == {"action": "allow", "findings": [], "summary": ""}
|
||||
mock_run.assert_not_called()
|
||||
mock_resolve.assert_not_called()
|
||||
|
||||
@patch("tools.tirith_security._load_security_config")
|
||||
def test_resolve_path_unsupported_caches_failure_without_probing(self, mock_cfg):
|
||||
"""The per-command resolver must also short-circuit on Windows so
|
||||
long-running gateways don't churn through `shutil.which` and disk
|
||||
I/O for every scanned command."""
|
||||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||||
_tirith_mod._resolved_path = None
|
||||
with patch("tools.tirith_security.is_platform_supported", return_value=False), \
|
||||
patch("tools.tirith_security.shutil.which") as mock_which:
|
||||
result = _tirith_mod._resolve_tirith_path("tirith")
|
||||
assert result == "tirith"
|
||||
mock_which.assert_not_called()
|
||||
assert _tirith_mod._resolved_path is _tirith_mod._INSTALL_FAILED
|
||||
assert _tirith_mod._install_failure_reason == "unsupported_platform"
|
||||
|
||||
@patch("tools.tirith_security._load_security_config")
|
||||
def test_explicit_path_still_honored_on_unsupported_platform(self, mock_cfg):
|
||||
"""If a user explicitly configured a tirith_path (e.g. they built it
|
||||
themselves under WSL), the unsupported-platform short-circuit must
|
||||
NOT override that — explicit config wins."""
|
||||
mock_cfg.return_value = {"tirith_enabled": True,
|
||||
"tirith_path": "/opt/custom/tirith",
|
||||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||||
_tirith_mod._resolved_path = None
|
||||
with patch("tools.tirith_security.is_platform_supported", return_value=False), \
|
||||
patch("os.path.isfile", return_value=True), \
|
||||
patch("os.access", return_value=True):
|
||||
result = _tirith_mod._resolve_tirith_path("/opt/custom/tirith")
|
||||
assert result == "/opt/custom/tirith"
|
||||
assert _tirith_mod._resolved_path == "/opt/custom/tirith"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Failed download caches the miss (Finding #1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -214,7 +214,12 @@ def _hermes_bin_dir() -> str:
|
|||
|
||||
|
||||
def _detect_target() -> str | None:
|
||||
"""Return the Rust target triple for the current platform, or None."""
|
||||
"""Return the Rust target triple for the current platform, or None.
|
||||
|
||||
Windows is intentionally unsupported — tirith does not ship a Windows
|
||||
build. Callers should treat `None` as "this platform will never have
|
||||
tirith" and silently fall back to pattern-matching guards.
|
||||
"""
|
||||
system = platform.system()
|
||||
machine = platform.machine().lower()
|
||||
|
||||
|
|
@ -236,6 +241,16 @@ def _detect_target() -> str | None:
|
|||
return f"{arch}-{plat}"
|
||||
|
||||
|
||||
def is_platform_supported() -> bool:
|
||||
"""True when tirith ships a prebuilt binary for this OS+arch.
|
||||
|
||||
Used by callers (CLI banner, etc.) to distinguish "tirith failed to
|
||||
install" from "tirith was never going to install here" — the latter
|
||||
is silent because there is nothing the user can do about it.
|
||||
"""
|
||||
return _detect_target() is not None
|
||||
|
||||
|
||||
def _download_file(url: str, dest: str, timeout: int = 10):
|
||||
"""Download a URL to a local file."""
|
||||
req = urllib.request.Request(url)
|
||||
|
|
@ -448,6 +463,15 @@ def _resolve_tirith_path(configured_path: str) -> str:
|
|||
explicit = _is_explicit_path(configured_path)
|
||||
install_failed = _resolved_path is _INSTALL_FAILED
|
||||
|
||||
# Platform has no tirith build (Windows etc.). Cache the verdict and
|
||||
# return the unexpanded configured path — the spawn loop will fail-open
|
||||
# via the dedupe'd OSError handler, but only after the first call; on
|
||||
# subsequent calls the fast-path above short-circuits before spawning.
|
||||
if not explicit and not is_platform_supported():
|
||||
_resolved_path = _INSTALL_FAILED
|
||||
_install_failure_reason = "unsupported_platform"
|
||||
return expanded
|
||||
|
||||
# Explicit path: check it and stop. Never auto-download a replacement.
|
||||
if explicit:
|
||||
if os.path.isfile(expanded) and os.access(expanded, os.X_OK):
|
||||
|
|
@ -574,6 +598,14 @@ def ensure_installed(*, log_failures: bool = True):
|
|||
return path
|
||||
return None
|
||||
|
||||
# Platform has no tirith build (e.g. Windows) — don't probe PATH,
|
||||
# don't start a download thread, don't write a disk failure marker.
|
||||
# Pattern-matching guards still run; this path stays silent.
|
||||
if not is_platform_supported():
|
||||
_resolved_path = _INSTALL_FAILED
|
||||
_install_failure_reason = "unsupported_platform"
|
||||
return None
|
||||
|
||||
configured_path = cfg["tirith_path"]
|
||||
explicit = _is_explicit_path(configured_path)
|
||||
expanded = os.path.expanduser(configured_path)
|
||||
|
|
@ -659,6 +691,12 @@ def check_command_security(command: str) -> dict:
|
|||
if not cfg["tirith_enabled"]:
|
||||
return {"action": "allow", "findings": [], "summary": ""}
|
||||
|
||||
# Unsupported platform (Windows etc.) — tirith has no binary here and
|
||||
# never will. Skip the resolver entirely so we don't even try to spawn.
|
||||
# Pattern-matching guards still run via the rest of approval.py.
|
||||
if not is_platform_supported():
|
||||
return {"action": "allow", "findings": [], "summary": ""}
|
||||
|
||||
tirith_path = _resolve_tirith_path(cfg["tirith_path"])
|
||||
timeout = cfg["tirith_timeout"]
|
||||
fail_open = cfg["tirith_fail_open"]
|
||||
|
|
|
|||
50
ui-tui/src/__tests__/approvalAction.test.ts
Normal file
50
ui-tui/src/__tests__/approvalAction.test.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { approvalAction } from '../components/prompts.js'
|
||||
|
||||
describe('approvalAction — pure key dispatch for ApprovalPrompt', () => {
|
||||
it('maps Esc to deny — parity with global Ctrl+C cancellation', () => {
|
||||
expect(approvalAction('', { escape: true }, 0)).toEqual({ kind: 'choose', choice: 'deny' })
|
||||
expect(approvalAction('', { escape: true }, 2)).toEqual({ kind: 'choose', choice: 'deny' })
|
||||
})
|
||||
|
||||
it('maps number keys 1..4 to once/session/always/deny in registration order', () => {
|
||||
expect(approvalAction('1', {}, 0)).toEqual({ kind: 'choose', choice: 'once' })
|
||||
expect(approvalAction('2', {}, 0)).toEqual({ kind: 'choose', choice: 'session' })
|
||||
expect(approvalAction('3', {}, 0)).toEqual({ kind: 'choose', choice: 'always' })
|
||||
expect(approvalAction('4', {}, 0)).toEqual({ kind: 'choose', choice: 'deny' })
|
||||
})
|
||||
|
||||
it('ignores out-of-range numbers', () => {
|
||||
expect(approvalAction('0', {}, 1)).toEqual({ kind: 'noop' })
|
||||
expect(approvalAction('5', {}, 1)).toEqual({ kind: 'noop' })
|
||||
expect(approvalAction('9', {}, 1)).toEqual({ kind: 'noop' })
|
||||
})
|
||||
|
||||
it('confirms the current selection on Enter', () => {
|
||||
expect(approvalAction('', { return: true }, 0)).toEqual({ kind: 'choose', choice: 'once' })
|
||||
expect(approvalAction('', { return: true }, 3)).toEqual({ kind: 'choose', choice: 'deny' })
|
||||
})
|
||||
|
||||
it('moves selection up/down within bounds', () => {
|
||||
expect(approvalAction('', { upArrow: true }, 2)).toEqual({ kind: 'move', delta: -1 })
|
||||
expect(approvalAction('', { downArrow: true }, 1)).toEqual({ kind: 'move', delta: 1 })
|
||||
})
|
||||
|
||||
it('clamps selection movement at the edges', () => {
|
||||
expect(approvalAction('', { upArrow: true }, 0)).toEqual({ kind: 'noop' })
|
||||
expect(approvalAction('', { downArrow: true }, 3)).toEqual({ kind: 'noop' })
|
||||
})
|
||||
|
||||
it('Esc beats numeric/return — denying is always the first interpretation', () => {
|
||||
// If a terminal somehow delivers Esc + a digit in the same event, deny
|
||||
// wins. Documents the precedence so a future refactor doesn't flip it.
|
||||
expect(approvalAction('1', { escape: true }, 0)).toEqual({ kind: 'choose', choice: 'deny' })
|
||||
expect(approvalAction('', { escape: true, return: true }, 1)).toEqual({ kind: 'choose', choice: 'deny' })
|
||||
})
|
||||
|
||||
it('returns noop for unrelated keystrokes (printable letters etc.)', () => {
|
||||
expect(approvalAction('a', {}, 0)).toEqual({ kind: 'noop' })
|
||||
expect(approvalAction(' ', {}, 0)).toEqual({ kind: 'noop' })
|
||||
})
|
||||
})
|
||||
|
|
@ -1,6 +1,46 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { applyVoiceRecordResponse } from '../app/useInputHandlers.js'
|
||||
import { applyVoiceRecordResponse, shouldFallThroughForScroll } from '../app/useInputHandlers.js'
|
||||
|
||||
const baseKey = {
|
||||
downArrow: false,
|
||||
pageDown: false,
|
||||
pageUp: false,
|
||||
shift: false,
|
||||
upArrow: false,
|
||||
wheelDown: false,
|
||||
wheelUp: false
|
||||
}
|
||||
|
||||
describe('shouldFallThroughForScroll — keep transcript scrolling alive during prompt overlays', () => {
|
||||
it('falls through for wheel scrolls', () => {
|
||||
expect(shouldFallThroughForScroll({ ...baseKey, wheelUp: true })).toBe(true)
|
||||
expect(shouldFallThroughForScroll({ ...baseKey, wheelDown: true })).toBe(true)
|
||||
})
|
||||
|
||||
it('falls through for PageUp / PageDown', () => {
|
||||
expect(shouldFallThroughForScroll({ ...baseKey, pageUp: true })).toBe(true)
|
||||
expect(shouldFallThroughForScroll({ ...baseKey, pageDown: true })).toBe(true)
|
||||
})
|
||||
|
||||
it('falls through for Shift+ArrowUp / Shift+ArrowDown', () => {
|
||||
expect(shouldFallThroughForScroll({ ...baseKey, shift: true, upArrow: true })).toBe(true)
|
||||
expect(shouldFallThroughForScroll({ ...baseKey, shift: true, downArrow: true })).toBe(true)
|
||||
})
|
||||
|
||||
it('does NOT fall through for plain arrows — those drive in-prompt selection', () => {
|
||||
expect(shouldFallThroughForScroll({ ...baseKey, upArrow: true })).toBe(false)
|
||||
expect(shouldFallThroughForScroll({ ...baseKey, downArrow: true })).toBe(false)
|
||||
})
|
||||
|
||||
it('does NOT fall through for plain Shift — without an arrow it is a no-op', () => {
|
||||
expect(shouldFallThroughForScroll({ ...baseKey, shift: true })).toBe(false)
|
||||
})
|
||||
|
||||
it('does NOT fall through for unrelated state (no scroll keys held)', () => {
|
||||
expect(shouldFallThroughForScroll(baseKey)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyVoiceRecordResponse', () => {
|
||||
it('reverts optimistic REC state when the gateway reports voice busy', () => {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,42 @@ import { getUiState } from './uiStore.js'
|
|||
|
||||
const isCtrl = (key: { ctrl: boolean }, ch: string, target: string) => key.ctrl && ch.toLowerCase() === target
|
||||
|
||||
/**
|
||||
* Approval / clarify / confirm overlays mount their own `useInput` handlers
|
||||
* for the in-prompt keys (arrows, numbers, Enter, sometimes Esc). The global
|
||||
* input handler used to early-return for any other key while one of those
|
||||
* overlays was up, which silently disabled transcript scrolling — the user
|
||||
* couldn't read context above the prompt that the prompt itself was asking
|
||||
* about. Returns true when the key is a transcript-scroll input that should
|
||||
* fall through to the global scroll handlers even while a prompt is active.
|
||||
*
|
||||
* Modifier-held wheel (precision mode) is included — a user who wants to
|
||||
* scroll a single line at a time during a prompt expects it to work.
|
||||
*/
|
||||
export function shouldFallThroughForScroll(key: {
|
||||
downArrow: boolean
|
||||
pageDown: boolean
|
||||
pageUp: boolean
|
||||
shift: boolean
|
||||
upArrow: boolean
|
||||
wheelDown: boolean
|
||||
wheelUp: boolean
|
||||
}): boolean {
|
||||
if (key.wheelUp || key.wheelDown) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (key.pageUp || key.pageDown) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (key.shift && (key.upArrow || key.downArrow)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function applyVoiceRecordResponse(
|
||||
response: null | VoiceRecordResponse,
|
||||
starting: boolean,
|
||||
|
|
@ -224,7 +260,18 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
|||
// handlers must receive keystrokes (arrow keys, numbers, Enter). Only
|
||||
// intercept Ctrl+C here so the user can deny/dismiss — all other keys
|
||||
// fall through to the component-level handlers.
|
||||
if (overlay.approval || overlay.clarify || overlay.confirm) {
|
||||
//
|
||||
// Scroll inputs (wheel / PageUp / PageDown / Shift+↑↓) are special:
|
||||
// they must reach the transcript scroll handlers below even with a
|
||||
// prompt up. Long-thread context the prompt is asking about often
|
||||
// lives above the visible viewport, and being unable to read it while
|
||||
// answering felt like the prompt had locked the entire UI. Explicitly
|
||||
// skip the prompt-overlay early-return for scroll keys so they fall
|
||||
// through to the wheel / PageUp / Shift+arrow handlers below.
|
||||
const promptOverlay = overlay.approval || overlay.clarify || overlay.confirm
|
||||
const fallThroughForScroll = promptOverlay && shouldFallThroughForScroll(key)
|
||||
|
||||
if (promptOverlay && !fallThroughForScroll) {
|
||||
if (isCtrl(key, ch, 'c')) {
|
||||
cancelOverlayFromCtrlC()
|
||||
}
|
||||
|
|
@ -298,7 +345,13 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
|||
patchOverlayState({ picker: false })
|
||||
}
|
||||
|
||||
return
|
||||
// When a prompt overlay is up and the user pressed a scroll key, fall
|
||||
// through to the global scroll handlers below instead of returning.
|
||||
// Otherwise nothing above this comment matched, and there's nothing
|
||||
// useful to do for an arbitrary key while blocked.
|
||||
if (!fallThroughForScroll) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (cState.completions.length && cState.input && cState.historyIdx === null && (key.upArrow || key.downArrow)) {
|
||||
|
|
|
|||
|
|
@ -11,28 +11,65 @@ const OPTS = ['once', 'session', 'always', 'deny'] as const
|
|||
const LABELS = { always: 'Always allow', deny: 'Deny', once: 'Allow once', session: 'Allow this session' } as const
|
||||
const CMD_PREVIEW_LINES = 10
|
||||
|
||||
type ApprovalKey = {
|
||||
downArrow?: boolean
|
||||
escape?: boolean
|
||||
return?: boolean
|
||||
upArrow?: boolean
|
||||
}
|
||||
|
||||
type ApprovalAction =
|
||||
| { kind: 'choose'; choice: (typeof OPTS)[number] }
|
||||
| { kind: 'move'; delta: -1 | 1 }
|
||||
| { kind: 'noop' }
|
||||
|
||||
/**
|
||||
* Pure key-dispatch for the approval prompt — exported so the regression
|
||||
* matrix (Esc, Ctrl+C-equivalent, number keys, Enter, ↑↓) is testable
|
||||
* without mounting React + Ink + a fake stdin. The component just maps the
|
||||
* action onto its own state setters.
|
||||
*
|
||||
* Esc and number keys both terminate the prompt; Esc maps to deny (parity
|
||||
* with the global Ctrl+C handler that already calls cancelOverlayFromCtrlC
|
||||
* for approvals). Numbers 1..OPTS.length pick the labelled choice. Enter
|
||||
* confirms the current selection. ↑/↓ moves the selection within bounds.
|
||||
*/
|
||||
export function approvalAction(ch: string, key: ApprovalKey, sel: number): ApprovalAction {
|
||||
if (key.escape) {
|
||||
return { kind: 'choose', choice: 'deny' }
|
||||
}
|
||||
|
||||
const n = parseInt(ch, 10)
|
||||
|
||||
if (n >= 1 && n <= OPTS.length) {
|
||||
return { kind: 'choose', choice: OPTS[n - 1]! }
|
||||
}
|
||||
|
||||
if (key.return) {
|
||||
return { kind: 'choose', choice: OPTS[sel]! }
|
||||
}
|
||||
|
||||
if (key.upArrow && sel > 0) {
|
||||
return { kind: 'move', delta: -1 }
|
||||
}
|
||||
|
||||
if (key.downArrow && sel < OPTS.length - 1) {
|
||||
return { kind: 'move', delta: 1 }
|
||||
}
|
||||
|
||||
return { kind: 'noop' }
|
||||
}
|
||||
|
||||
export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) {
|
||||
const [sel, setSel] = useState(0)
|
||||
|
||||
useInput((ch, key) => {
|
||||
if (key.upArrow && sel > 0) {
|
||||
setSel(s => s - 1)
|
||||
}
|
||||
const action = approvalAction(ch, key, sel)
|
||||
|
||||
if (key.downArrow && sel < OPTS.length - 1) {
|
||||
setSel(s => s + 1)
|
||||
}
|
||||
|
||||
const n = parseInt(ch, 10)
|
||||
|
||||
if (n >= 1 && n <= OPTS.length) {
|
||||
onChoice(OPTS[n - 1]!)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (key.return) {
|
||||
onChoice(OPTS[sel]!)
|
||||
if (action.kind === 'choose') {
|
||||
onChoice(action.choice)
|
||||
} else if (action.kind === 'move') {
|
||||
setSel(s => s + action.delta)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -71,7 +108,7 @@ export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) {
|
|||
</Text>
|
||||
))}
|
||||
|
||||
<Text color={t.color.muted}>↑/↓ select · Enter confirm · 1-4 quick pick · Ctrl+C deny</Text>
|
||||
<Text color={t.color.muted}>↑/↓ select · Enter confirm · 1-4 quick pick · Esc/Ctrl+C deny</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -537,6 +537,8 @@ security:
|
|||
|
||||
When `tirith_fail_open` is `true` (default), commands proceed if tirith is not installed or times out. Set to `false` in high-security environments to block commands when tirith is unavailable.
|
||||
|
||||
Tirith ships prebuilt binaries for Linux (x86_64 / aarch64) and macOS (x86_64 / arm64). On platforms with no prebuilt binary (Windows, etc.), tirith is silently skipped — pattern-matching guards still run, and the CLI does not surface an "unavailable" banner. To use tirith on Windows, run Hermes under WSL.
|
||||
|
||||
Tirith's verdict integrates with the approval flow: safe commands pass through, while both suspicious and blocked commands trigger user approval with the full tirith findings (severity, title, description, safer alternatives). Users can approve or deny — the default choice is deny to keep unattended scenarios secure.
|
||||
|
||||
### Context File Injection Protection
|
||||
|
|
|
|||
|
|
@ -602,7 +602,7 @@ def build_sidebar_items(entries: list[tuple[dict[str, Any], dict[str, Any]]]) ->
|
|||
else:
|
||||
optional[meta["category"]].append(meta)
|
||||
|
||||
def cat_section(bucket: dict[str, list[dict[str, Any]]]) -> list[dict]:
|
||||
def cat_section(bucket: dict[str, list[dict[str, Any]]], source: str) -> list[dict]:
|
||||
result = []
|
||||
for category in sorted(bucket):
|
||||
items = sorted(bucket[category], key=lambda m: m["slug"])
|
||||
|
|
@ -610,6 +610,13 @@ def build_sidebar_items(entries: list[tuple[dict[str, Any], dict[str, Any]]]) ->
|
|||
{
|
||||
"type": "category",
|
||||
"label": category,
|
||||
# Docusaurus generates a translation key from the label by
|
||||
# default (e.g. sidebar.docs.category.productivity). When
|
||||
# the same category name appears under both Bundled and
|
||||
# Optional, the duplicate keys break i18n extraction and
|
||||
# fail the build. Scope each category by source to keep
|
||||
# the keys unique.
|
||||
"key": f"skills-{source}-{category}",
|
||||
"collapsed": True,
|
||||
"items": [sidebar_doc_id(m) for m in items],
|
||||
}
|
||||
|
|
@ -617,8 +624,8 @@ def build_sidebar_items(entries: list[tuple[dict[str, Any], dict[str, Any]]]) ->
|
|||
return result
|
||||
|
||||
return {
|
||||
"bundled_categories": cat_section(bundled),
|
||||
"optional_categories": cat_section(optional),
|
||||
"bundled_categories": cat_section(bundled, "bundled"),
|
||||
"optional_categories": cat_section(optional, "optional"),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -633,6 +640,8 @@ def _render_sidebar_item(item: Any, indent: int) -> list[str]:
|
|||
lines.append(f"{pad}{{")
|
||||
lines.append(f"{pad} type: 'category',")
|
||||
lines.append(f"{pad} label: '{item['label']}',")
|
||||
if item.get("key"):
|
||||
lines.append(f"{pad} key: '{item['key']}',")
|
||||
if item.get("collapsed", True):
|
||||
lines.append(f"{pad} collapsed: true,")
|
||||
lines.append(f"{pad} items: [")
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ const sidebars: SidebarsConfig = {
|
|||
{
|
||||
type: 'category',
|
||||
label: 'apple',
|
||||
key: 'skills-bundled-apple',
|
||||
collapsed: true,
|
||||
items: [
|
||||
'user-guide/skills/bundled/apple/apple-apple-notes',
|
||||
|
|
@ -133,6 +134,7 @@ const sidebars: SidebarsConfig = {
|
|||
{
|
||||
type: 'category',
|
||||
label: 'autonomous-ai-agents',
|
||||
key: 'skills-bundled-autonomous-ai-agents',
|
||||
collapsed: true,
|
||||
items: [
|
||||
'user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-claude-code',
|
||||
|
|
@ -144,6 +146,7 @@ const sidebars: SidebarsConfig = {
|
|||
{
|
||||
type: 'category',
|
||||
label: 'creative',
|
||||
key: 'skills-bundled-creative',
|
||||
collapsed: true,
|
||||
items: [
|
||||
'user-guide/skills/bundled/creative/creative-architecture-diagram',
|
||||
|
|
@ -170,6 +173,7 @@ const sidebars: SidebarsConfig = {
|
|||
{
|
||||
type: 'category',
|
||||
label: 'data-science',
|
||||
key: 'skills-bundled-data-science',
|
||||
collapsed: true,
|
||||
items: [
|
||||
'user-guide/skills/bundled/data-science/data-science-jupyter-live-kernel',
|
||||
|
|
@ -178,6 +182,7 @@ const sidebars: SidebarsConfig = {
|
|||
{
|
||||
type: 'category',
|
||||
label: 'devops',
|
||||
key: 'skills-bundled-devops',
|
||||
collapsed: true,
|
||||
items: [
|
||||
'user-guide/skills/bundled/devops/devops-kanban-orchestrator',
|
||||
|
|
@ -188,6 +193,7 @@ const sidebars: SidebarsConfig = {
|
|||
{
|
||||
type: 'category',
|
||||
label: 'dogfood',
|
||||
key: 'skills-bundled-dogfood',
|
||||
collapsed: true,
|
||||
items: [
|
||||
'user-guide/skills/bundled/dogfood/dogfood-dogfood',
|
||||
|
|
@ -196,6 +202,7 @@ const sidebars: SidebarsConfig = {
|
|||
{
|
||||
type: 'category',
|
||||
label: 'email',
|
||||
key: 'skills-bundled-email',
|
||||
collapsed: true,
|
||||
items: [
|
||||
'user-guide/skills/bundled/email/email-himalaya',
|
||||
|
|
@ -204,6 +211,7 @@ const sidebars: SidebarsConfig = {
|
|||
{
|
||||
type: 'category',
|
||||
label: 'gaming',
|
||||
key: 'skills-bundled-gaming',
|
||||
collapsed: true,
|
||||
items: [
|
||||
'user-guide/skills/bundled/gaming/gaming-minecraft-modpack-server',
|
||||
|
|
@ -213,6 +221,7 @@ const sidebars: SidebarsConfig = {
|
|||
{
|
||||
type: 'category',
|
||||
label: 'github',
|
||||
key: 'skills-bundled-github',
|
||||
collapsed: true,
|
||||
items: [
|
||||
'user-guide/skills/bundled/github/github-codebase-inspection',
|
||||
|
|
@ -226,6 +235,7 @@ const sidebars: SidebarsConfig = {
|
|||
{
|
||||
type: 'category',
|
||||
label: 'mcp',
|
||||
key: 'skills-bundled-mcp',
|
||||
collapsed: true,
|
||||
items: [
|
||||
'user-guide/skills/bundled/mcp/mcp-native-mcp',
|
||||
|
|
@ -234,6 +244,7 @@ const sidebars: SidebarsConfig = {
|
|||
{
|
||||
type: 'category',
|
||||
label: 'media',
|
||||
key: 'skills-bundled-media',
|
||||
collapsed: true,
|
||||
items: [
|
||||
'user-guide/skills/bundled/media/media-gif-search',
|
||||
|
|
@ -246,6 +257,7 @@ const sidebars: SidebarsConfig = {
|
|||
{
|
||||
type: 'category',
|
||||
label: 'mlops',
|
||||
key: 'skills-bundled-mlops',
|
||||
collapsed: true,
|
||||
items: [
|
||||
'user-guide/skills/bundled/mlops/mlops-models-audiocraft',
|
||||
|
|
@ -262,6 +274,7 @@ const sidebars: SidebarsConfig = {
|
|||
{
|
||||
type: 'category',
|
||||
label: 'note-taking',
|
||||
key: 'skills-bundled-note-taking',
|
||||
collapsed: true,
|
||||
items: [
|
||||
'user-guide/skills/bundled/note-taking/note-taking-obsidian',
|
||||
|
|
@ -270,6 +283,7 @@ const sidebars: SidebarsConfig = {
|
|||
{
|
||||
type: 'category',
|
||||
label: 'productivity',
|
||||
key: 'skills-bundled-productivity',
|
||||
collapsed: true,
|
||||
items: [
|
||||
'user-guide/skills/bundled/productivity/productivity-airtable',
|
||||
|
|
@ -286,6 +300,7 @@ const sidebars: SidebarsConfig = {
|
|||
{
|
||||
type: 'category',
|
||||
label: 'red-teaming',
|
||||
key: 'skills-bundled-red-teaming',
|
||||
collapsed: true,
|
||||
items: [
|
||||
'user-guide/skills/bundled/red-teaming/red-teaming-godmode',
|
||||
|
|
@ -294,6 +309,7 @@ const sidebars: SidebarsConfig = {
|
|||
{
|
||||
type: 'category',
|
||||
label: 'research',
|
||||
key: 'skills-bundled-research',
|
||||
collapsed: true,
|
||||
items: [
|
||||
'user-guide/skills/bundled/research/research-arxiv',
|
||||
|
|
@ -306,6 +322,7 @@ const sidebars: SidebarsConfig = {
|
|||
{
|
||||
type: 'category',
|
||||
label: 'smart-home',
|
||||
key: 'skills-bundled-smart-home',
|
||||
collapsed: true,
|
||||
items: [
|
||||
'user-guide/skills/bundled/smart-home/smart-home-openhue',
|
||||
|
|
@ -314,6 +331,7 @@ const sidebars: SidebarsConfig = {
|
|||
{
|
||||
type: 'category',
|
||||
label: 'social-media',
|
||||
key: 'skills-bundled-social-media',
|
||||
collapsed: true,
|
||||
items: [
|
||||
'user-guide/skills/bundled/social-media/social-media-xurl',
|
||||
|
|
@ -322,6 +340,7 @@ const sidebars: SidebarsConfig = {
|
|||
{
|
||||
type: 'category',
|
||||
label: 'software-development',
|
||||
key: 'skills-bundled-software-development',
|
||||
collapsed: true,
|
||||
items: [
|
||||
'user-guide/skills/bundled/software-development/software-development-debugging-hermes-tui-commands',
|
||||
|
|
@ -340,6 +359,7 @@ const sidebars: SidebarsConfig = {
|
|||
{
|
||||
type: 'category',
|
||||
label: 'yuanbao',
|
||||
key: 'skills-bundled-yuanbao',
|
||||
collapsed: true,
|
||||
items: [
|
||||
'user-guide/skills/bundled/yuanbao/yuanbao-yuanbao',
|
||||
|
|
@ -355,6 +375,7 @@ const sidebars: SidebarsConfig = {
|
|||
{
|
||||
type: 'category',
|
||||
label: 'autonomous-ai-agents',
|
||||
key: 'skills-optional-autonomous-ai-agents',
|
||||
collapsed: true,
|
||||
items: [
|
||||
'user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-blackbox',
|
||||
|
|
@ -364,6 +385,7 @@ const sidebars: SidebarsConfig = {
|
|||
{
|
||||
type: 'category',
|
||||
label: 'blockchain',
|
||||
key: 'skills-optional-blockchain',
|
||||
collapsed: true,
|
||||
items: [
|
||||
'user-guide/skills/optional/blockchain/blockchain-evm',
|
||||
|
|
@ -374,6 +396,7 @@ const sidebars: SidebarsConfig = {
|
|||
{
|
||||
type: 'category',
|
||||
label: 'communication',
|
||||
key: 'skills-optional-communication',
|
||||
collapsed: true,
|
||||
items: [
|
||||
'user-guide/skills/optional/communication/communication-one-three-one-rule',
|
||||
|
|
@ -382,6 +405,7 @@ const sidebars: SidebarsConfig = {
|
|||
{
|
||||
type: 'category',
|
||||
label: 'creative',
|
||||
key: 'skills-optional-creative',
|
||||
collapsed: true,
|
||||
items: [
|
||||
'user-guide/skills/optional/creative/creative-blender-mcp',
|
||||
|
|
@ -394,6 +418,7 @@ const sidebars: SidebarsConfig = {
|
|||
{
|
||||
type: 'category',
|
||||
label: 'devops',
|
||||
key: 'skills-optional-devops',
|
||||
collapsed: true,
|
||||
items: [
|
||||
'user-guide/skills/optional/devops/devops-cli',
|
||||
|
|
@ -404,6 +429,7 @@ const sidebars: SidebarsConfig = {
|
|||
{
|
||||
type: 'category',
|
||||
label: 'dogfood',
|
||||
key: 'skills-optional-dogfood',
|
||||
collapsed: true,
|
||||
items: [
|
||||
'user-guide/skills/optional/dogfood/dogfood-adversarial-ux-test',
|
||||
|
|
@ -412,6 +438,7 @@ const sidebars: SidebarsConfig = {
|
|||
{
|
||||
type: 'category',
|
||||
label: 'email',
|
||||
key: 'skills-optional-email',
|
||||
collapsed: true,
|
||||
items: [
|
||||
'user-guide/skills/optional/email/email-agentmail',
|
||||
|
|
@ -420,6 +447,7 @@ const sidebars: SidebarsConfig = {
|
|||
{
|
||||
type: 'category',
|
||||
label: 'finance',
|
||||
key: 'skills-optional-finance',
|
||||
collapsed: true,
|
||||
items: [
|
||||
'user-guide/skills/optional/finance/finance-3-statement-model',
|
||||
|
|
@ -435,6 +463,7 @@ const sidebars: SidebarsConfig = {
|
|||
{
|
||||
type: 'category',
|
||||
label: 'health',
|
||||
key: 'skills-optional-health',
|
||||
collapsed: true,
|
||||
items: [
|
||||
'user-guide/skills/optional/health/health-fitness-nutrition',
|
||||
|
|
@ -444,6 +473,7 @@ const sidebars: SidebarsConfig = {
|
|||
{
|
||||
type: 'category',
|
||||
label: 'mcp',
|
||||
key: 'skills-optional-mcp',
|
||||
collapsed: true,
|
||||
items: [
|
||||
'user-guide/skills/optional/mcp/mcp-fastmcp',
|
||||
|
|
@ -453,6 +483,7 @@ const sidebars: SidebarsConfig = {
|
|||
{
|
||||
type: 'category',
|
||||
label: 'migration',
|
||||
key: 'skills-optional-migration',
|
||||
collapsed: true,
|
||||
items: [
|
||||
'user-guide/skills/optional/migration/migration-openclaw-migration',
|
||||
|
|
@ -461,6 +492,7 @@ const sidebars: SidebarsConfig = {
|
|||
{
|
||||
type: 'category',
|
||||
label: 'mlops',
|
||||
key: 'skills-optional-mlops',
|
||||
collapsed: true,
|
||||
items: [
|
||||
'user-guide/skills/optional/mlops/mlops-accelerate',
|
||||
|
|
@ -496,6 +528,7 @@ const sidebars: SidebarsConfig = {
|
|||
{
|
||||
type: 'category',
|
||||
label: 'productivity',
|
||||
key: 'skills-optional-productivity',
|
||||
collapsed: true,
|
||||
items: [
|
||||
'user-guide/skills/optional/productivity/productivity-canvas',
|
||||
|
|
@ -510,6 +543,7 @@ const sidebars: SidebarsConfig = {
|
|||
{
|
||||
type: 'category',
|
||||
label: 'research',
|
||||
key: 'skills-optional-research',
|
||||
collapsed: true,
|
||||
items: [
|
||||
'user-guide/skills/optional/research/research-bioinformatics',
|
||||
|
|
@ -526,6 +560,7 @@ const sidebars: SidebarsConfig = {
|
|||
{
|
||||
type: 'category',
|
||||
label: 'security',
|
||||
key: 'skills-optional-security',
|
||||
collapsed: true,
|
||||
items: [
|
||||
'user-guide/skills/optional/security/security-1password',
|
||||
|
|
@ -536,6 +571,7 @@ const sidebars: SidebarsConfig = {
|
|||
{
|
||||
type: 'category',
|
||||
label: 'software-development',
|
||||
key: 'skills-optional-software-development',
|
||||
collapsed: true,
|
||||
items: [
|
||||
'user-guide/skills/optional/software-development/software-development-rest-graphql-debug',
|
||||
|
|
@ -544,6 +580,7 @@ const sidebars: SidebarsConfig = {
|
|||
{
|
||||
type: 'category',
|
||||
label: 'web-development',
|
||||
key: 'skills-optional-web-development',
|
||||
collapsed: true,
|
||||
items: [
|
||||
'user-guide/skills/optional/web-development/web-development-page-agent',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue