Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions omlx/admin/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@
"status.integrations.opencode_desc": "AI coding assistant in terminal",
"status.integrations.openclaw_desc": "AI assistant with messaging integration",
"status.integrations.pi_desc": "Terminal AI coding agent",
"status.integrations.crush_desc": "Glamorous terminal-first coding agent",
"status.integrations.openclaw_tools_profile": "Tools",
"status.engines.section_label": "Engine Versions",
"status.engines.unknown_version": "unknown",
Expand Down
1 change: 1 addition & 0 deletions omlx/admin/i18n/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@
"status.integrations.opencode_desc": "ターミナルAIコーディングアシスタント",
"status.integrations.openclaw_desc": "メッセージング統合AIアシスタント",
"status.integrations.pi_desc": "Terminal AI coding agent",
"status.integrations.crush_desc": "華やかなターミナル特化型コーディングエージェント",
"status.integrations.openclaw_tools_profile": "ツール",
"status.engines.section_label": "エンジンバージョン",
"status.engines.unknown_version": "不明",
Expand Down
1 change: 1 addition & 0 deletions omlx/admin/i18n/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@
"status.integrations.opencode_desc": "터미널 AI 코딩 어시스턴트",
"status.integrations.openclaw_desc": "메시징 통합 AI 어시스턴트",
"status.integrations.pi_desc": "Terminal AI coding agent",
"status.integrations.crush_desc": "터미널 우선 AI 코딩 에이전트",
"status.integrations.openclaw_tools_profile": "도구",
"status.engines.section_label": "엔진 버전",
"status.engines.unknown_version": "알 수 없음",
Expand Down
1 change: 1 addition & 0 deletions omlx/admin/i18n/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@
"status.integrations.opencode_desc": "終端機 AI 程式碼助理",
"status.integrations.openclaw_desc": "訊息整合 AI 助理",
"status.integrations.pi_desc": "Terminal AI coding agent",
"status.integrations.crush_desc": "華麗的終端機優先程式碼助理",
"status.integrations.openclaw_tools_profile": "工具",
"status.engines.section_label": "引擎版本",
"status.engines.unknown_version": "未知",
Expand Down
1 change: 1 addition & 0 deletions omlx/admin/i18n/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@
"status.integrations.opencode_desc": "终端 AI 编程助手",
"status.integrations.openclaw_desc": "消息集成 AI 助手",
"status.integrations.pi_desc": "Terminal AI coding agent",
"status.integrations.crush_desc": "华丽的终端优先编程助手",
"status.integrations.openclaw_tools_profile": "工具",
"status.engines.section_label": "引擎版本",
"status.engines.unknown_version": "未知",
Expand Down
8 changes: 7 additions & 1 deletion omlx/admin/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ class GlobalSettingsRequest(BaseModel):
integrations_opencode_model: Optional[str] = None
integrations_openclaw_model: Optional[str] = None
integrations_pi_model: Optional[str] = None
integrations_crush_model: Optional[str] = None
integrations_openclaw_tools_profile: Optional[Literal["minimal", "coding", "messaging", "full"]] = None

# UI settings
Expand Down Expand Up @@ -2227,6 +2228,7 @@ async def get_global_settings(is_admin: bool = Depends(require_admin)):
"opencode_model": global_settings.integrations.opencode_model,
"openclaw_model": global_settings.integrations.openclaw_model,
"pi_model": global_settings.integrations.pi_model,
"crush_model": global_settings.integrations.crush_model,
"openclaw_tools_profile": global_settings.integrations.openclaw_tools_profile,
},
"system": {
Expand Down Expand Up @@ -2597,6 +2599,9 @@ async def update_global_settings(
if "integrations_pi_model" in request.model_fields_set:
global_settings.integrations.pi_model = request.integrations_pi_model
integrations_changed = True
if "integrations_crush_model" in request.model_fields_set:
global_settings.integrations.crush_model = request.integrations_crush_model
integrations_changed = True
if "integrations_openclaw_tools_profile" in request.model_fields_set:
global_settings.integrations.openclaw_tools_profile = (
request.integrations_openclaw_tools_profile
Expand All @@ -2610,7 +2615,8 @@ async def update_global_settings(
f"codex={global_settings.integrations.codex_model}, "
f"opencode={global_settings.integrations.opencode_model}, "
f"openclaw={global_settings.integrations.openclaw_model}, "
f"pi={global_settings.integrations.pi_model}"
f"pi={global_settings.integrations.pi_model}, "
f"crush={global_settings.integrations.crush_model}"
)

# Apply UI settings
Expand Down
13 changes: 12 additions & 1 deletion omlx/admin/static/js/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
network: { http_proxy: '', https_proxy: '', no_proxy: '', ca_bundle: '' },
auth: { api_key_set: false, api_key: '', skip_api_key_verification: false, sub_keys: [] },
claude_code: { context_scaling_enabled: false, target_context_size: 200000, mode: 'cloud', opus_model: null, sonnet_model: null, haiku_model: null },
integrations: { codex_model: null, opencode_model: null, openclaw_model: null, pi_model: null, openclaw_tools_profile: 'full' },
integrations: { codex_model: null, opencode_model: null, openclaw_model: null, pi_model: null, crush_model: null, openclaw_tools_profile: 'full' },
ui: { language: 'en' },
idle_timeout: { idle_timeout_seconds: null },
system: { total_memory_bytes: 0, total_memory: '', auto_model_memory: '', ssd_total_bytes: 0, ssd_total: '' },
Expand Down Expand Up @@ -1869,6 +1869,16 @@
return parts.join(' ');
},

get crushCommand() {
const cli = this.stats.cli_prefix || 'omlx';
const model = this.globalSettings.integrations.crush_model || 'select-a-model';
const parts = [`${this.shellQuote(cli)} launch crush --model ${this.shellQuote(model)}`];
if (this.stats.api_key) {
parts.push(`--api-key ${this.shellQuote(this.stats.api_key)}`);
}
return parts.join(' ');
},

async saveIntegrationSettings() {
try {
const response = await fetch('/admin/api/global-settings', {
Expand All @@ -1879,6 +1889,7 @@
integrations_opencode_model: this.globalSettings.integrations.opencode_model,
integrations_openclaw_model: this.globalSettings.integrations.openclaw_model,
integrations_pi_model: this.globalSettings.integrations.pi_model,
integrations_crush_model: this.globalSettings.integrations.crush_model,
integrations_openclaw_tools_profile: this.globalSettings.integrations.openclaw_tools_profile,
}),
});
Expand Down
44 changes: 44 additions & 0 deletions omlx/admin/templates/dashboard/_status.html
Original file line number Diff line number Diff line change
Expand Up @@ -713,6 +713,50 @@ <h3 class="text-2xl font-bold tracking-tight text-neutral-900">{{ t('status.head
</div>
</div>
</div>

<div class="border-t border-neutral-100 my-2"></div>

<!-- Crush -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-base font-bold text-neutral-800">Crush</span>
<span class="text-xs text-neutral-400">{{ t('status.integrations.crush_desc') }}</span>
</div>
<div class="flex flex-col sm:flex-row gap-4 items-start">
<div class="w-full sm:w-64 sm:flex-shrink-0">
<label class="block text-xs font-bold uppercase tracking-wider text-neutral-500 mb-1.5">{{ t('status.integrations.model') }}</label>
<select x-model="globalSettings.integrations.crush_model"
@change="globalSettings.integrations.crush_model = globalSettings.integrations.crush_model || null; saveIntegrationSettings()"
class="w-full px-3 py-2 text-sm border border-neutral-200 rounded-lg bg-white focus:ring-2 focus:ring-neutral-900 focus:border-neutral-900 transition-all">
<option value="">{{ t('status.integrations.select_model') }}</option>
<template x-for="m in llmModels" :key="m.id">
<option :value="m.id" x-text="m.id"></option>
</template>
</select>
<div x-show="globalSettings.integrations.crush_model && llmModels.length > 0 && !llmModels.find(m => m.id === globalSettings.integrations.crush_model)"
x-cloak
class="mt-1 flex items-center gap-1.5 text-xs text-amber-600">
<i data-lucide="alert-triangle" class="w-3 h-3 flex-shrink-0"></i>
<span>{{ t('status.integrations.model_unavailable') }}</span>
</div>
</div>
<div class="flex-1 min-w-0">
<label class="block text-xs font-bold uppercase tracking-wider text-neutral-500 mb-1.5">{{ t('status.integrations.command') }}</label>
<div class="relative group">
<pre class="px-4 py-3 pr-12 bg-neutral-100 text-neutral-800 rounded-lg text-xs font-mono overflow-x-auto whitespace-pre-wrap break-all leading-relaxed"
x-text="crushCommand"></pre>
<button x-data="{ copied: false }"
@click="copyToClipboard(crushCommand); copied = true; setTimeout(() => copied = false, 2000)"
class="absolute top-2.5 right-2.5 p-1.5 rounded-md transition-all opacity-0 group-hover:opacity-100"
:class="copied ? 'text-green-500 opacity-100' : 'bg-white/80 text-neutral-400 hover:text-neutral-700'"
:title="window.t('status.integrations.copy_command_tooltip')">
<svg x-show="!copied" class="w-3.5 h-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
<svg x-show="copied" x-cloak class="w-3.5 h-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
</button>
</div>
</div>
</div>
</div>
</div>
</div>

Expand Down
4 changes: 2 additions & 2 deletions omlx/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -662,13 +662,13 @@ def main():
launch_parser = subparsers.add_parser(
"launch",
help="Launch an external tool with oMLX integration",
description="Configure and launch external coding tools (Codex, OpenCode, OpenClaw, Pi) "
description="Configure and launch external coding tools (Codex, OpenCode, OpenClaw, Pi, Crush) "
"to use the running oMLX server.",
)
launch_parser.add_argument(
"tool",
type=str,
help="Tool to launch: codex, opencode, openclaw, pi, or 'list' to show available",
help="Tool to launch: codex, opencode, openclaw, pi, crush, or 'list' to show available",
)
launch_parser.add_argument(
"--model",
Expand Down
2 changes: 2 additions & 0 deletions omlx/integrations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@

from omlx.integrations.base import Integration
from omlx.integrations.codex import CodexIntegration
from omlx.integrations.crush import CrushIntegration
from omlx.integrations.opencode import OpenCodeIntegration
from omlx.integrations.openclaw import OpenClawIntegration
from omlx.integrations.pi import PiIntegration

INTEGRATIONS: dict[str, Integration] = {
"codex": CodexIntegration(),
"crush": CrushIntegration(),
"opencode": OpenCodeIntegration(),
"openclaw": OpenClawIntegration(),
"pi": PiIntegration(),
Expand Down
80 changes: 80 additions & 0 deletions omlx/integrations/crush.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# SPDX-License-Identifier: Apache-2.0
"""Crush integration."""

from __future__ import annotations

import os
from pathlib import Path

from omlx.integrations.base import Integration
from omlx.utils.install import get_cli_prefix


class CrushIntegration(Integration):
"""Crush integration that writes ~/.config/crush/crush.json."""

CONFIG_PATH = Path.home() / ".config" / "crush" / "crush.json"

def __init__(self):
super().__init__(
name="crush",
display_name="Crush",
type="config_file",
install_check="crush",
install_hint="brew install charmbracelet/tap/crush",
)

def get_command(
self, port: int, api_key: str, model: str, host: str = "127.0.0.1"
) -> str:
return (
f"{get_cli_prefix()} "
f"launch crush --model {model or 'select-a-model'}"
)

def configure(
self,
port: int,
api_key: str,
model: str,
host: str = "127.0.0.1",
context_window: int | None = None,
max_tokens: int | None = None,
) -> None:
def updater(config: dict) -> None:
config.setdefault("providers", {})
provider_config: dict = {
"name": "oMLX",
"type": "openai-compat",
"base_url": f"http://{host}:{port}/v1",
"api_key": api_key or "omlx",
}
if model:
model_entry: dict = {"id": model, "name": model}
if context_window:
model_entry["context_window"] = context_window
if max_tokens:
model_entry["default_max_tokens"] = max_tokens
provider_config["models"] = [model_entry]
config["providers"]["omlx"] = provider_config

if model:
config.setdefault("models", {})
selection = {"provider": "omlx", "model": model}
config["models"]["large"] = selection
config["models"]["small"] = dict(selection)

self._write_json_config(self.CONFIG_PATH, updater)

def launch(self, port: int, api_key: str, model: str, host: str = "127.0.0.1", **kwargs) -> None:
context_window = kwargs.pop("context_window", None)
max_tokens = kwargs.pop("max_tokens", None)
self.configure(
port, api_key, model, host=host,
context_window=context_window, max_tokens=max_tokens,
)

env = os.environ.copy()
args = ["crush"]

os.execvpe("crush", args, env)
5 changes: 4 additions & 1 deletion omlx/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -635,12 +635,13 @@ def from_dict(cls, data: dict[str, Any]) -> ClaudeCodeSettings:

@dataclass
class IntegrationSettings:
"""Other integrations settings (Codex, OpenCode, OpenClaw, Pi)."""
"""Other integrations settings (Codex, OpenCode, OpenClaw, Pi, Crush)."""

codex_model: str | None = None
opencode_model: str | None = None
openclaw_model: str | None = None
pi_model: str | None = None
crush_model: str | None = None
openclaw_tools_profile: str = "coding"

def to_dict(self) -> dict[str, Any]:
Expand All @@ -650,6 +651,7 @@ def to_dict(self) -> dict[str, Any]:
"opencode_model": self.opencode_model,
"openclaw_model": self.openclaw_model,
"pi_model": self.pi_model,
"crush_model": self.crush_model,
"openclaw_tools_profile": self.openclaw_tools_profile,
}

Expand All @@ -661,6 +663,7 @@ def from_dict(cls, data: dict[str, Any]) -> IntegrationSettings:
opencode_model=data.get("opencode_model", None),
openclaw_model=data.get("openclaw_model", None),
pi_model=data.get("pi_model", None),
crush_model=data.get("crush_model", None),
openclaw_tools_profile=data.get("openclaw_tools_profile", "coding"),
)

Expand Down
Loading