Skip to content

Commit dd98a6d

Browse files
ElmatadorZclaude
andcommitted
Linux support: platform-aware discovery instead of Windows-shaped guesses
The backend already branched on os.name for shell and process handling, so execution was never Windows-only. What was Windows-only were the *discovery* lists — the places the system looks for optional components. On Linux those lists simply never matched, so a capability that was present appeared absent. Nothing crashed; things quietly did not work, which is worse. Now probed per platform: - Obsidian vault: adds ~/Notes, ~/vault, and the Linux sync-client conventions (Nextcloud, Dropbox, Syncthing) plus /vault and /data/obsidian for container mounts; macOS gets the iCloud-synced vault location. The Windows and OneDrive paths are kept, now behind an os.name check rather than tried everywhere. - Obsidian vault registry: was APPDATA only. Adds the macOS Application Support path, $XDG_CONFIG_HOME (falling back to ~/.config), and the Flatpak location under ~/.var/app/md.obsidian.Obsidian. - Tesseract: PATH is now tried first — which is how every Linux package manager and Homebrew install it — before falling back to platform-specific locations. tessdata likewise gains the Debian, Fedora, Arch and Homebrew directories. - Agent workspace: ~/Desktop/workspace was assumed to exist. On a headless server, or a system with a non-English XDG name, it does not. Falls back to ~/skynetclaw-workspace. docs/LINUX.md documents what a Linux install actually needs: system packages per distribution, a local model, the full discovery table, a systemd --user unit, the port map with an explicit warning that 8766 has no authentication and must not be exposed to a network, and the known gaps — .bat/.ps1 do not apply, macOS is not in CI, language data for OCR installs separately, and some agent prompt guidance still reads Windows-first. Listing the gaps rather than leaving them to be discovered is the point; a platform claim that has not been tested is not a claim worth making. Verified: 601 tests pass, import OK with 267 routes, discovery still resolves correctly on the Windows host it was changed on. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6c16f00 commit dd98a6d

6 files changed

Lines changed: 268 additions & 21 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,9 @@ See [NOTICE](NOTICE) for the full statement.
215215
| **Windows** | ✅ verified in CI | `install.bat` · `start.bat` |
216216
| **macOS** | ⚠️ should work (POSIX path); **not yet in CI** | use the commands above |
217217

218+
Linux specifics — system packages, discovery paths, systemd unit, known gaps:
219+
**[docs/LINUX.md](docs/LINUX.md)**.
220+
218221
CI installs from `requirements.txt` alone on Ubuntu and Windows across Python 3.10 / 3.11 / 3.12,
219222
runs the migration, boots the server, and requires `/api/system/health` to report `ok`. If that
220223
badge is red, the claim that this works is not currently true.

backend/doc_reader.py

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,23 +72,41 @@ def _html(path: str) -> str:
7272

7373

7474
def _find_tesseract() -> str:
75+
"""Locate the tesseract binary. PATH first — that is how it is installed on
76+
Linux (apt/dnf/pacman), macOS (brew), and increasingly on Windows too.
77+
The explicit lists are fallbacks for installers that skip PATH."""
7578
import shutil
7679
cmd = shutil.which("tesseract")
7780
if cmd:
7881
return cmd
79-
for p in (r"C:\Program Files\Tesseract-OCR\tesseract.exe",
80-
r"C:\Program Files (x86)\Tesseract-OCR\tesseract.exe"):
82+
if os.name == "nt":
83+
cands = (r"C:\Program Files\Tesseract-OCR\tesseract.exe",
84+
r"C:\Program Files (x86)\Tesseract-OCR\tesseract.exe")
85+
else:
86+
cands = ("/usr/bin/tesseract", # Debian/Ubuntu, Fedora, Arch
87+
"/usr/local/bin/tesseract", # source builds, Intel brew
88+
"/opt/homebrew/bin/tesseract", # Apple-silicon brew
89+
"/snap/bin/tesseract")
90+
for p in cands:
8191
if os.path.exists(p):
8292
return p
8393
return ""
8494

8595

8696
def _find_tessdata() -> str:
8797
"""A tessdata dir that contains Thai (tha.traineddata)."""
98+
home = os.path.expanduser("~")
8899
cands = [os.environ.get("TESSDATA_PREFIX", ""),
89-
os.path.join(os.path.expanduser("~"), "llamacpp_test", "tessdata"),
90-
r"C:\Program Files\Tesseract-OCR\tessdata",
91-
r"C:\Program Files (x86)\Tesseract-OCR\tessdata"]
100+
os.path.join(home, "llamacpp_test", "tessdata")]
101+
if os.name == "nt":
102+
cands += [r"C:\Program Files\Tesseract-OCR\tessdata",
103+
r"C:\Program Files (x86)\Tesseract-OCR\tessdata"]
104+
else:
105+
cands += ["/usr/share/tesseract-ocr/5/tessdata", # Debian/Ubuntu (v5)
106+
"/usr/share/tesseract-ocr/4.00/tessdata", # older Debian
107+
"/usr/share/tessdata", # Fedora/Arch
108+
"/usr/local/share/tessdata",
109+
"/opt/homebrew/share/tessdata"] # Apple-silicon brew
92110
for d in cands:
93111
if d and os.path.exists(os.path.join(d, "tha.traineddata")):
94112
return d

backend/main.py

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3915,8 +3915,16 @@ async def browse_file(title: str = "Select File", filter: str = "All Files|*.*")
39153915
@app.get("/api/browse/obsidian-vaults")
39163916
async def find_obsidian_vaults():
39173917
vaults=[]
3918-
for cfg in [Path(os.environ.get("APPDATA",""))/"obsidian"/"obsidian.json",
3919-
Path.home()/"AppData"/"Roaming"/"obsidian"/"obsidian.json"]:
3918+
# Obsidian stores its vault registry in the host's config location, which
3919+
# differs per platform. Probe all of them; missing ones are simply skipped.
3920+
_cfgs = [Path(os.environ.get("APPDATA", "")) / "obsidian" / "obsidian.json",
3921+
Path.home() / "AppData" / "Roaming" / "obsidian" / "obsidian.json", # Windows
3922+
Path.home() / "Library" / "Application Support" / "obsidian" / "obsidian.json", # macOS
3923+
Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config"))
3924+
/ "obsidian" / "obsidian.json", # Linux
3925+
Path.home() / ".var" / "app" / "md.obsidian.Obsidian" / "config"
3926+
/ "obsidian" / "obsidian.json"] # Linux flatpak
3927+
for cfg in _cfgs:
39203928
if cfg.exists():
39213929
try:
39223930
data=json.loads(cfg.read_text(encoding="utf-8"))
@@ -7419,11 +7427,19 @@ async def api_upload(file: UploadFile = File(...), workspace_folder: str = Form(
74197427
import doc_reader as _dr
74207428
ws = (workspace_folder or "").strip()
74217429
if not ws:
7422-
for _cand in [os.path.join(os.path.expanduser("~"), "OneDrive", "Desktop", "workspace"),
7423-
os.path.join(os.path.expanduser("~"), "Desktop", "workspace")]:
7430+
_home = os.path.expanduser("~")
7431+
# Desktop is not guaranteed on Linux (headless, or a non-English
7432+
# XDG name), so fall back to a plain ~/skynetclaw-workspace there.
7433+
_cands = [os.path.join(_home, "OneDrive", "Desktop", "workspace"),
7434+
os.path.join(_home, "Desktop", "workspace"),
7435+
os.path.join(_home, "skynetclaw-workspace")]
7436+
for _cand in _cands:
74247437
if os.path.isdir(_cand):
74257438
ws = _cand; break
7426-
ws = ws or os.path.join(os.path.expanduser("~"), "Desktop", "workspace")
7439+
if not ws:
7440+
ws = (os.path.join(_home, "Desktop", "workspace")
7441+
if os.path.isdir(os.path.join(_home, "Desktop"))
7442+
else os.path.join(_home, "skynetclaw-workspace"))
74277443
updir = os.path.join(ws, "uploads")
74287444
os.makedirs(updir, exist_ok=True)
74297445
safe = os.path.basename(file.filename or "upload.bin").replace("..", "_")
@@ -7455,11 +7471,19 @@ async def api_news_report(req: Request):
74557471
topics = [t.strip() for t in _re2.split(r"[,\n;|]", topics) if t.strip()]
74567472
ws = body.get("workspace_folder")
74577473
if not ws:
7458-
for _cand in [os.path.join(os.path.expanduser("~"), "OneDrive", "Desktop", "workspace"),
7459-
os.path.join(os.path.expanduser("~"), "Desktop", "workspace")]:
7474+
_home = os.path.expanduser("~")
7475+
# Desktop is not guaranteed on Linux (headless, or a non-English
7476+
# XDG name), so fall back to a plain ~/skynetclaw-workspace there.
7477+
_cands = [os.path.join(_home, "OneDrive", "Desktop", "workspace"),
7478+
os.path.join(_home, "Desktop", "workspace"),
7479+
os.path.join(_home, "skynetclaw-workspace")]
7480+
for _cand in _cands:
74607481
if os.path.isdir(_cand):
74617482
ws = _cand; break
7462-
ws = ws or os.path.join(os.path.expanduser("~"), "Desktop", "workspace")
7483+
if not ws:
7484+
ws = (os.path.join(_home, "Desktop", "workspace")
7485+
if os.path.isdir(os.path.join(_home, "Desktop"))
7486+
else os.path.join(_home, "skynetclaw-workspace"))
74637487
fname = (body.get("filename") or "news_report.html").strip()
74647488
if not fname.lower().endswith(".html"):
74657489
fname += ".html"

backend/obsidian_tools.py

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,15 @@
1111
Vault discovery (in order):
1212
1. settings.json → 'obsidian_vault'
1313
2. settings.json → 'obsidian_vaults' (list, first entry)
14-
3. C:/Users/<user>/Documents/Obsidian Vault
15-
4. C:/Users/<user>/Obsidian
16-
5. D:/Obsidian
14+
3. cross-platform conventional locations under $HOME
15+
4. host-specific locations (OneDrive on Windows, iCloud on macOS,
16+
Nextcloud/Dropbox and container mounts on Linux)
17+
18+
A vault is optional — SkynetClaw runs without one; the Scout's four tools
19+
simply report that none is configured.
1720
"""
1821
from __future__ import annotations
19-
import json, os, re
22+
import json, os, re, sys
2023
from pathlib import Path
2124
from typing import Any, Dict, List, Optional
2225

@@ -42,15 +45,36 @@ def get_vault() -> Optional[Path]:
4245
if isinstance(s.get("obsidian_vaults"), list) and s["obsidian_vaults"]:
4346
candidates.append(s["obsidian_vaults"][0])
4447

45-
# OS-typical paths
48+
# OS-typical paths. The same Obsidian install lives somewhere different on
49+
# each platform, so probe the host's conventions rather than assuming one.
4650
home = Path.home()
4751
candidates += [
4852
str(home / "Documents" / "Obsidian Vault"),
4953
str(home / "Obsidian"),
50-
str(home / "OneDrive" / "Documents" / "Obsidian Vault"),
51-
"D:/Obsidian",
52-
"D:/Notes",
54+
str(home / "Notes"),
55+
str(home / "vault"),
5356
]
57+
if os.name == "nt": # Windows
58+
candidates += [
59+
str(home / "OneDrive" / "Documents" / "Obsidian Vault"),
60+
str(home / "OneDrive" / "Obsidian"),
61+
"D:/Obsidian",
62+
"D:/Notes",
63+
]
64+
elif sys.platform == "darwin": # macOS
65+
candidates += [
66+
str(home / "Library" / "Mobile Documents"
67+
/ "iCloud~md~obsidian" / "Documents"), # iCloud-synced vaults
68+
str(home / "Documents" / "Obsidian"),
69+
]
70+
else: # Linux / BSD
71+
candidates += [
72+
str(home / "Nextcloud" / "Obsidian"),
73+
str(home / "Dropbox" / "Obsidian"),
74+
str(home / "Sync" / "Obsidian"),
75+
"/vault", # docker-compose mount
76+
"/data/obsidian",
77+
]
5478
for c in candidates:
5579
p = Path(c)
5680
if p.exists() and p.is_dir():

docs/INSTALL.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,8 @@ personal file has crept in.
155155

156156
Choosing and mixing models: **[MODELS.md](MODELS.md)**.
157157

158+
Linux specifics — packages, discovery paths, systemd: **[LINUX.md](LINUX.md)**.
159+
158160
---
159161

160162
## Before enabling execution

docs/LINUX.md

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
# Running SkynetClaw on Linux
2+
3+
SkynetClaw is developed on Windows but the backend is platform-neutral, and CI
4+
proves that on Ubuntu on every push. This page covers what a Linux install
5+
actually needs.
6+
7+
---
8+
9+
## Quick path
10+
11+
```bash
12+
git clone https://github.com/ElmatadorZ/skynetclaw.git
13+
cd skynetclaw
14+
./start.sh
15+
```
16+
17+
`start.sh` creates the virtualenv, installs the five dependencies, seeds both
18+
config templates, runs the database migration, warns if Ollama is unreachable,
19+
and starts the server. It is idempotent — run it again any time.
20+
21+
Or with `make`:
22+
23+
```bash
24+
make setup && make run
25+
```
26+
27+
Or skip Python entirely:
28+
29+
```bash
30+
docker compose up -d
31+
docker compose exec ollama ollama pull llama3.1:8b
32+
```
33+
34+
---
35+
36+
## System packages
37+
38+
Only Python is strictly required. Everything else is optional and degrades
39+
gracefully — a missing component disables one capability, it does not stop the
40+
system.
41+
42+
### Debian / Ubuntu
43+
44+
```bash
45+
sudo apt update
46+
sudo apt install -y python3 python3-venv python3-pip curl
47+
48+
# optional — OCR for scanned PDFs and images
49+
sudo apt install -y tesseract-ocr tesseract-ocr-tha
50+
```
51+
52+
### Fedora / RHEL
53+
54+
```bash
55+
sudo dnf install -y python3 python3-pip curl
56+
sudo dnf install -y tesseract tesseract-langpack-tha # optional
57+
```
58+
59+
### Arch
60+
61+
```bash
62+
sudo pacman -S python python-pip curl
63+
sudo pacman -S tesseract tesseract-data-tha # optional
64+
```
65+
66+
OCR is discovered from `PATH` first, so a package-manager install needs no
67+
configuration. Without it, `doc_reader` simply reports that OCR is unavailable.
68+
69+
---
70+
71+
## A local model
72+
73+
```bash
74+
curl -fsSL https://ollama.com/install.sh | sh
75+
ollama serve & # or: systemctl --user start ollama
76+
ollama pull llama3.1:8b
77+
ollama pull qwen2.5-coder:7b # the execution path
78+
```
79+
80+
**No GPU required.** A 7–8B model runs on CPU; it is slower, not broken. With an
81+
NVIDIA GPU and the container toolkit installed, Ollama uses it automatically.
82+
83+
Full provider matrix, including cloud APIs: **[MODELS.md](MODELS.md)**.
84+
85+
---
86+
87+
## What Linux discovers automatically
88+
89+
These were Windows-shaped and are now probed per platform:
90+
91+
| Capability | Linux locations searched |
92+
|---|---|
93+
| **Obsidian vault** | `~/Documents/Obsidian Vault`, `~/Obsidian`, `~/Notes`, `~/vault`, `~/Nextcloud/Obsidian`, `~/Dropbox/Obsidian`, `~/Sync/Obsidian`, `/vault`, `/data/obsidian` |
94+
| **Obsidian vault registry** | `$XDG_CONFIG_HOME/obsidian/obsidian.json`, `~/.config/obsidian/obsidian.json`, and the Flatpak path under `~/.var/app/md.obsidian.Obsidian/` |
95+
| **Tesseract binary** | `PATH`, then `/usr/bin`, `/usr/local/bin`, `/opt/homebrew/bin`, `/snap/bin` |
96+
| **tessdata** | `$TESSDATA_PREFIX`, `/usr/share/tesseract-ocr/5/tessdata`, `/usr/share/tessdata`, `/usr/local/share/tessdata` |
97+
| **Agent workspace** | `~/Desktop/workspace` when a Desktop exists, otherwise `~/skynetclaw-workspace` — headless servers and non-English XDG names are handled |
98+
99+
Set an explicit vault path in `backend/settings.json` to skip discovery entirely.
100+
101+
---
102+
103+
## Shell execution on Linux
104+
105+
The agent's `shell_command` tool runs through the platform's own shell. The
106+
system already branches on `os.name`, so PowerShell-specific handling applies
107+
only on Windows; on Linux commands go to `/bin/sh` unchanged.
108+
109+
This is the highest-risk capability in the system. The GPS-2 gate is
110+
deny-by-default and irreversible actions require a human gate — leave those on.
111+
See [NOTICE](../NOTICE).
112+
113+
---
114+
115+
## Running as a service
116+
117+
`systemd --user` unit, adjusting `WorkingDirectory`:
118+
119+
```ini
120+
# ~/.config/systemd/user/skynetclaw.service
121+
[Unit]
122+
Description=SkynetClaw
123+
After=network.target
124+
125+
[Service]
126+
Type=simple
127+
WorkingDirectory=%h/skynetclaw
128+
ExecStart=%h/skynetclaw/start.sh
129+
Restart=on-failure
130+
RestartSec=10
131+
132+
[Install]
133+
WantedBy=default.target
134+
```
135+
136+
```bash
137+
systemctl --user daemon-reload
138+
systemctl --user enable --now skynetclaw
139+
systemctl --user status skynetclaw
140+
journalctl --user -u skynetclaw -f
141+
```
142+
143+
For a system-wide unit, run it as a dedicated unprivileged user whose home is the
144+
only workspace the agent can reach.
145+
146+
---
147+
148+
## Ports
149+
150+
| Port | Service | Bound to |
151+
|---|---|---|
152+
| 8766 | SkynetClaw backend | `127.0.0.1` by default |
153+
| 11434 | Ollama | localhost |
154+
| 8080 | execution runtime (optional) | localhost |
155+
156+
The backend binds to loopback deliberately. **Do not expose 8766 to a network**
157+
without putting authentication in front of it — the API can run tools and write
158+
files, and it has no built-in authentication.
159+
160+
---
161+
162+
## Known Linux gaps
163+
164+
Stated rather than discovered later:
165+
166+
- **`.bat` / `.ps1` launchers do not apply.** Use `start.sh`, `make`, or Docker.
167+
- **macOS is not in CI.** It should work — the Linux code path is POSIX — but it
168+
is untested, so it is marked as such in the README rather than claimed.
169+
- **Tesseract language data** for non-English OCR must be installed separately
170+
(`tesseract-ocr-tha` and friends).
171+
- **File-path handling in agent prompts** is written with Windows examples in
172+
places. It functions on Linux, but a model may occasionally produce
173+
Windows-style guidance in its explanations.
174+
175+
If you hit something else, please open an issue with the distribution, the
176+
Python version, and the failing output.

0 commit comments

Comments
 (0)