alinux 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- alinux-0.1.0/PKG-INFO +89 -0
- alinux-0.1.0/README.md +66 -0
- alinux-0.1.0/alinux/__init__.py +3 -0
- alinux-0.1.0/alinux/__main__.py +6 -0
- alinux-0.1.0/alinux/agent.py +98 -0
- alinux-0.1.0/alinux/fluxbox.py +34 -0
- alinux-0.1.0/alinux/gui.py +70 -0
- alinux-0.1.0/alinux/main.py +66 -0
- alinux-0.1.0/alinux/system_controls.py +156 -0
- alinux-0.1.0/pyproject.toml +46 -0
- alinux-0.1.0/tests/test_agent.py +19 -0
- alinux-0.1.0/tests/test_agent_provider.py +24 -0
- alinux-0.1.0/tests/test_fluxbox.py +14 -0
- alinux-0.1.0/tests/test_system_controls.py +15 -0
alinux-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: alinux
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Alinux AI orchestration shell for minimal Linux desktops
|
|
5
|
+
Author: Alinux contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: ai,automation,desktop,fluxbox,linux
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Desktop Environment
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Requires-Dist: openai<2,>=1.40.0
|
|
17
|
+
Requires-Dist: pydantic<3,>=2.7.0
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: build>=1.2.0; extra == 'dev'
|
|
20
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
21
|
+
Requires-Dist: ruff>=0.6.0; extra == 'dev'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# Alinux
|
|
25
|
+
|
|
26
|
+
Alinux is an AI orchestration shell for a minimal Debian 12 desktop running
|
|
27
|
+
Fluxbox. It converts natural-language requests into a small, validated set of
|
|
28
|
+
desktop actions.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
python3 -m venv .venv
|
|
34
|
+
. .venv/bin/activate
|
|
35
|
+
python -m pip install .
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
For model access, export a key and optionally choose a provider/model. Any
|
|
39
|
+
OpenAI-compatible API can be configured with a custom endpoint:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
export ALINUX_API_KEY='your-key'
|
|
43
|
+
export ALINUX_MODEL='gpt-4o-mini'
|
|
44
|
+
export ALINUX_API_BASE_URL='https://your-provider.example/v1'
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Groq is supported directly, including Alinux's built-in open model preset:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
export ALINUX_PROVIDER='groq'
|
|
51
|
+
export ALINUX_API_KEY='your-groq-key'
|
|
52
|
+
alinux
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
With `ALINUX_PROVIDER=groq` and no model override, Alinux uses
|
|
56
|
+
`llama-3.3-70b-versatile` at Groq's OpenAI-compatible endpoint. You can also
|
|
57
|
+
select it explicitly with `ALINUX_MODEL=llama-3.3-70b-versatile`.
|
|
58
|
+
|
|
59
|
+
The desktop hooks expect `xdotool`, `amixer`, `free`, and `uptime`. Missing
|
|
60
|
+
tools are reported as readable responses rather than terminating Alinux.
|
|
61
|
+
|
|
62
|
+
## Run
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
alinux
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Open the graphical Alinux window from a terminal with:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
alinux open
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
To add **Open Alinux** to the Fluxbox desktop context menu, run this once
|
|
75
|
+
inside the Fluxbox session:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
alinux install-fluxbox-menu
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Then restart or reconfigure Fluxbox. The installer updates
|
|
82
|
+
`~/.fluxbox/menu` without replacing your existing entries. The GUI requires
|
|
83
|
+
Debian's `python3-tk` package and a valid `DISPLAY`.
|
|
84
|
+
|
|
85
|
+
Type requests such as `show system status`, `turn the volume down`, or
|
|
86
|
+
`open the terminal`. Enter `quit` or press `Ctrl-D` to exit.
|
|
87
|
+
|
|
88
|
+
Alinux never executes arbitrary shell text from the model. Only the operations
|
|
89
|
+
listed in its system prompt and `system_controls.py` are routable.
|
alinux-0.1.0/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Alinux
|
|
2
|
+
|
|
3
|
+
Alinux is an AI orchestration shell for a minimal Debian 12 desktop running
|
|
4
|
+
Fluxbox. It converts natural-language requests into a small, validated set of
|
|
5
|
+
desktop actions.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
python3 -m venv .venv
|
|
11
|
+
. .venv/bin/activate
|
|
12
|
+
python -m pip install .
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
For model access, export a key and optionally choose a provider/model. Any
|
|
16
|
+
OpenAI-compatible API can be configured with a custom endpoint:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
export ALINUX_API_KEY='your-key'
|
|
20
|
+
export ALINUX_MODEL='gpt-4o-mini'
|
|
21
|
+
export ALINUX_API_BASE_URL='https://your-provider.example/v1'
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Groq is supported directly, including Alinux's built-in open model preset:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
export ALINUX_PROVIDER='groq'
|
|
28
|
+
export ALINUX_API_KEY='your-groq-key'
|
|
29
|
+
alinux
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
With `ALINUX_PROVIDER=groq` and no model override, Alinux uses
|
|
33
|
+
`llama-3.3-70b-versatile` at Groq's OpenAI-compatible endpoint. You can also
|
|
34
|
+
select it explicitly with `ALINUX_MODEL=llama-3.3-70b-versatile`.
|
|
35
|
+
|
|
36
|
+
The desktop hooks expect `xdotool`, `amixer`, `free`, and `uptime`. Missing
|
|
37
|
+
tools are reported as readable responses rather than terminating Alinux.
|
|
38
|
+
|
|
39
|
+
## Run
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
alinux
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Open the graphical Alinux window from a terminal with:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
alinux open
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
To add **Open Alinux** to the Fluxbox desktop context menu, run this once
|
|
52
|
+
inside the Fluxbox session:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
alinux install-fluxbox-menu
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Then restart or reconfigure Fluxbox. The installer updates
|
|
59
|
+
`~/.fluxbox/menu` without replacing your existing entries. The GUI requires
|
|
60
|
+
Debian's `python3-tk` package and a valid `DISPLAY`.
|
|
61
|
+
|
|
62
|
+
Type requests such as `show system status`, `turn the volume down`, or
|
|
63
|
+
`open the terminal`. Enter `quit` or press `Ctrl-D` to exit.
|
|
64
|
+
|
|
65
|
+
Alinux never executes arbitrary shell text from the model. Only the operations
|
|
66
|
+
listed in its system prompt and `system_controls.py` are routable.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""LLM-backed intent parsing for the Alinux shell."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
10
|
+
|
|
11
|
+
BUILT_IN_MODELS = {
|
|
12
|
+
"llama-3.3-70b-versatile": {
|
|
13
|
+
"provider": "groq",
|
|
14
|
+
"base_url": "https://api.groq.com/openai/v1",
|
|
15
|
+
},
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AgentAction(BaseModel):
|
|
20
|
+
"""The only action contract accepted by the desktop loop."""
|
|
21
|
+
|
|
22
|
+
model_config = ConfigDict(extra="forbid")
|
|
23
|
+
|
|
24
|
+
action: Literal["system_command", "launch_app", "respond_to_user"]
|
|
25
|
+
parameter: str = Field(max_length=500)
|
|
26
|
+
thought: str = Field(default="", max_length=1000)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
SYSTEM_PROMPT = """You are the core brain of Alinux, a minimal Debian 12 Linux desktop using Fluxbox.
|
|
30
|
+
Translate the user's request into exactly one safe structured action.
|
|
31
|
+
You may use system_command for status, windows, volume up, volume down, or volume mute.
|
|
32
|
+
Use launch_app only with terminal, browser, or file manager.
|
|
33
|
+
Use respond_to_user for questions, unsupported requests, or requests requiring explanation.
|
|
34
|
+
Never invent shell commands, never request arbitrary command execution, and never claim an action succeeded.
|
|
35
|
+
Return only valid JSON matching this schema:
|
|
36
|
+
{"action":"system_command|launch_app|respond_to_user","parameter":"...","thought":"..."}
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Agent:
|
|
41
|
+
"""Convert natural language into validated actions using an OpenAI-compatible API."""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
model: str | None = None,
|
|
46
|
+
api_key: str | None = None,
|
|
47
|
+
base_url: str | None = None,
|
|
48
|
+
provider: str | None = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
configured_model = model or os.getenv("ALINUX_MODEL")
|
|
51
|
+
self.model = configured_model or "gpt-4o-mini"
|
|
52
|
+
model_config = BUILT_IN_MODELS.get(self.model, {})
|
|
53
|
+
self.provider = (provider or os.getenv("ALINUX_PROVIDER") or model_config.get("provider", "openai")).lower()
|
|
54
|
+
self.base_url = base_url or os.getenv("ALINUX_API_BASE_URL") or model_config.get("base_url")
|
|
55
|
+
self.api_key = api_key or os.getenv("ALINUX_API_KEY") or os.getenv("OPENAI_API_KEY")
|
|
56
|
+
if self.provider == "groq" and configured_model is None and model is None:
|
|
57
|
+
self.model = "llama-3.3-70b-versatile"
|
|
58
|
+
self.base_url = self.base_url or "https://api.groq.com/openai/v1"
|
|
59
|
+
self._client = None
|
|
60
|
+
if self.api_key:
|
|
61
|
+
try:
|
|
62
|
+
from openai import OpenAI
|
|
63
|
+
|
|
64
|
+
client_options = {"api_key": self.api_key}
|
|
65
|
+
if self.base_url:
|
|
66
|
+
client_options["base_url"] = self.base_url
|
|
67
|
+
self._client = OpenAI(**client_options)
|
|
68
|
+
except ImportError:
|
|
69
|
+
self._client = None
|
|
70
|
+
|
|
71
|
+
def decide(self, user_text: str) -> AgentAction:
|
|
72
|
+
"""Return a validated action, falling back safely when the LLM is unavailable."""
|
|
73
|
+
if not user_text.strip():
|
|
74
|
+
return AgentAction(action="respond_to_user", parameter="Please enter a request.")
|
|
75
|
+
if self._client is None:
|
|
76
|
+
return AgentAction(
|
|
77
|
+
action="respond_to_user",
|
|
78
|
+
parameter="Alinux needs ALINUX_API_KEY (or OPENAI_API_KEY) and the openai package to interpret requests.",
|
|
79
|
+
)
|
|
80
|
+
try:
|
|
81
|
+
response = self._client.chat.completions.create(
|
|
82
|
+
model=self.model,
|
|
83
|
+
temperature=0,
|
|
84
|
+
response_format={"type": "json_object"},
|
|
85
|
+
messages=[
|
|
86
|
+
{"role": "system", "content": SYSTEM_PROMPT},
|
|
87
|
+
{"role": "user", "content": user_text.strip()},
|
|
88
|
+
],
|
|
89
|
+
)
|
|
90
|
+
content = response.choices[0].message.content or ""
|
|
91
|
+
return AgentAction.model_validate(json.loads(content))
|
|
92
|
+
except (json.JSONDecodeError, ValidationError, IndexError, TypeError, ValueError) as exc:
|
|
93
|
+
return AgentAction(
|
|
94
|
+
action="respond_to_user",
|
|
95
|
+
parameter=f"Alinux could not understand the model response safely: {exc}.",
|
|
96
|
+
)
|
|
97
|
+
except Exception as exc: # noqa: BLE001 - model failures must not stop the shell
|
|
98
|
+
return AgentAction(action="respond_to_user", parameter=f"Alinux model error: {exc}")
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Fluxbox menu integration for Alinux."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
_MENU_ENTRY = "[exec] (Open Alinux) {alinux open}"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def install_fluxbox_menu(menu_path: str | os.PathLike[str] | None = None) -> str:
|
|
13
|
+
"""Add an Open Alinux entry to the user's Fluxbox menu idempotently."""
|
|
14
|
+
path = Path(menu_path).expanduser() if menu_path else Path.home() / ".fluxbox" / "menu"
|
|
15
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
16
|
+
existing = path.read_text(encoding="utf-8") if path.exists() else "[begin] (Fluxbox)\n[end]\n"
|
|
17
|
+
if _MENU_ENTRY in existing:
|
|
18
|
+
return f"Alinux is already in the Fluxbox menu at {path}."
|
|
19
|
+
lines = existing.splitlines()
|
|
20
|
+
end_index = next((index for index, line in enumerate(lines) if line.strip() == "[end]"), len(lines))
|
|
21
|
+
lines.insert(end_index, _MENU_ENTRY)
|
|
22
|
+
content = "\n".join(lines).rstrip() + "\n"
|
|
23
|
+
fd, temporary_path = tempfile.mkstemp(prefix=".alinux-menu-", dir=path.parent, text=True)
|
|
24
|
+
try:
|
|
25
|
+
with os.fdopen(fd, "w", encoding="utf-8") as temporary_file:
|
|
26
|
+
temporary_file.write(content)
|
|
27
|
+
os.replace(temporary_path, path)
|
|
28
|
+
except Exception:
|
|
29
|
+
try:
|
|
30
|
+
os.unlink(temporary_path)
|
|
31
|
+
except OSError:
|
|
32
|
+
pass
|
|
33
|
+
raise
|
|
34
|
+
return f"Added Open Alinux to the Fluxbox menu at {path}. Restart or reconfigure Fluxbox to see it."
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Tkinter desktop interface for Alinux."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tkinter as tk
|
|
6
|
+
from tkinter import ttk
|
|
7
|
+
|
|
8
|
+
from .agent import Agent
|
|
9
|
+
from .main import _dispatch
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AlinuxWindow:
|
|
13
|
+
"""Small desktop chat window that routes requests through the Agent."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, root: tk.Tk, agent: Agent | None = None) -> None:
|
|
16
|
+
self.root = root
|
|
17
|
+
self.agent = agent or Agent()
|
|
18
|
+
self.root.title("Alinux")
|
|
19
|
+
self.root.geometry("640x460")
|
|
20
|
+
self.root.minsize(480, 320)
|
|
21
|
+
|
|
22
|
+
frame = ttk.Frame(root, padding=16)
|
|
23
|
+
frame.pack(fill=tk.BOTH, expand=True)
|
|
24
|
+
ttk.Label(frame, text="Alinux", font=("TkDefaultFont", 18, "bold")).pack(anchor=tk.W)
|
|
25
|
+
ttk.Label(frame, text="Linux desktop control through natural language").pack(anchor=tk.W, pady=(0, 12))
|
|
26
|
+
|
|
27
|
+
self.output = tk.Text(frame, height=16, state=tk.DISABLED, wrap=tk.WORD)
|
|
28
|
+
self.output.pack(fill=tk.BOTH, expand=True)
|
|
29
|
+
self.output.tag_configure("user", foreground="#245b9e")
|
|
30
|
+
self.output.tag_configure("assistant", foreground="#1f6b45")
|
|
31
|
+
|
|
32
|
+
controls = ttk.Frame(frame)
|
|
33
|
+
controls.pack(fill=tk.X, pady=(12, 0))
|
|
34
|
+
self.input = ttk.Entry(controls)
|
|
35
|
+
self.input.pack(side=tk.LEFT, fill=tk.X, expand=True)
|
|
36
|
+
self.input.bind("<Return>", lambda _event: self.submit())
|
|
37
|
+
ttk.Button(controls, text="Send", command=self.submit).pack(side=tk.LEFT, padx=(8, 0))
|
|
38
|
+
self.input.focus_set()
|
|
39
|
+
self._write("Alinux is ready. Ask for system status, volume changes, or an app to open.\n", "assistant")
|
|
40
|
+
|
|
41
|
+
def _write(self, text: str, tag: str) -> None:
|
|
42
|
+
self.output.configure(state=tk.NORMAL)
|
|
43
|
+
self.output.insert(tk.END, text, tag)
|
|
44
|
+
self.output.see(tk.END)
|
|
45
|
+
self.output.configure(state=tk.DISABLED)
|
|
46
|
+
|
|
47
|
+
def submit(self) -> None:
|
|
48
|
+
request = self.input.get().strip()
|
|
49
|
+
if not request:
|
|
50
|
+
return
|
|
51
|
+
self.input.delete(0, tk.END)
|
|
52
|
+
self._write(f"You: {request}\n", "user")
|
|
53
|
+
try:
|
|
54
|
+
response = _dispatch(self.agent.decide(request))
|
|
55
|
+
except Exception as exc: # noqa: BLE001 - GUI must remain open after a request failure
|
|
56
|
+
response = f"Alinux recovered from an error: {exc}"
|
|
57
|
+
self._write(f"Alinux: {response}\n\n", "assistant")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def open_gui(agent: Agent | None = None) -> int:
|
|
61
|
+
"""Open the Alinux desktop window and return a process exit status."""
|
|
62
|
+
try:
|
|
63
|
+
root = tk.Tk()
|
|
64
|
+
except (ImportError, tk.TclError) as exc:
|
|
65
|
+
print(f"Could not open the Alinux GUI: {exc}")
|
|
66
|
+
print("Install python3-tk and ensure DISPLAY points to the Fluxbox session.")
|
|
67
|
+
return 1
|
|
68
|
+
AlinuxWindow(root, agent)
|
|
69
|
+
root.mainloop()
|
|
70
|
+
return 0
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Command-line entry point for Alinux."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from . import system_controls
|
|
8
|
+
from .agent import Agent
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _dispatch(agent_action) -> str:
|
|
12
|
+
if agent_action.action == "launch_app":
|
|
13
|
+
return system_controls.launch_app(agent_action.parameter)
|
|
14
|
+
if agent_action.action == "system_command":
|
|
15
|
+
return system_controls.execute_system_command(agent_action.parameter)
|
|
16
|
+
return agent_action.parameter
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _interactive() -> None:
|
|
20
|
+
"""Run the resilient interactive terminal loop."""
|
|
21
|
+
print("Alinux ready. Type a request, or 'quit' to exit.")
|
|
22
|
+
agent = Agent()
|
|
23
|
+
while True:
|
|
24
|
+
try:
|
|
25
|
+
user_text = input("alinux> ").strip()
|
|
26
|
+
except (EOFError, KeyboardInterrupt):
|
|
27
|
+
print("\nAlinux stopped.")
|
|
28
|
+
return
|
|
29
|
+
if user_text.lower() in {"quit", "exit"}:
|
|
30
|
+
print("Alinux stopped.")
|
|
31
|
+
return
|
|
32
|
+
try:
|
|
33
|
+
print(_dispatch(agent.decide(user_text)))
|
|
34
|
+
except Exception as exc: # noqa: BLE001 - the interactive loop must recover
|
|
35
|
+
print(f"Alinux recovered from an error: {exc}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def main() -> int:
|
|
39
|
+
"""Run the GUI, Fluxbox installer, or terminal interface."""
|
|
40
|
+
parser = argparse.ArgumentParser(prog="alinux", description="Alinux Linux desktop assistant")
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"command",
|
|
43
|
+
nargs="?",
|
|
44
|
+
choices=("open", "install-fluxbox-menu"),
|
|
45
|
+
help="open the GUI or install its Fluxbox context-menu entry",
|
|
46
|
+
)
|
|
47
|
+
args = parser.parse_args()
|
|
48
|
+
if args.command == "open":
|
|
49
|
+
try:
|
|
50
|
+
from .gui import open_gui
|
|
51
|
+
except ImportError:
|
|
52
|
+
print("Could not open the Alinux GUI: install Debian's python3-tk package.")
|
|
53
|
+
return 1
|
|
54
|
+
|
|
55
|
+
return open_gui()
|
|
56
|
+
if args.command == "install-fluxbox-menu":
|
|
57
|
+
from .fluxbox import install_fluxbox_menu
|
|
58
|
+
|
|
59
|
+
print(install_fluxbox_menu())
|
|
60
|
+
return 0
|
|
61
|
+
_interactive()
|
|
62
|
+
return 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
if __name__ == "__main__":
|
|
66
|
+
main()
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Small, predictable Linux desktop control functions for Alinux.
|
|
2
|
+
|
|
3
|
+
All subprocess calls use argument lists and timeouts. Operations intentionally
|
|
4
|
+
cover a narrow set of desktop actions so an LLM cannot directly execute an
|
|
5
|
+
arbitrary shell command.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
|
|
15
|
+
_DEFAULT_TIMEOUT = 8
|
|
16
|
+
_ALLOWED_APPS = {
|
|
17
|
+
"terminal": ("x-terminal-emulator",),
|
|
18
|
+
"browser": ("xdg-open", "https://www.google.com"),
|
|
19
|
+
"file manager": ("xdg-open", os.path.expanduser("~")),
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _run(command: Sequence[str], timeout: int = _DEFAULT_TIMEOUT) -> subprocess.CompletedProcess[str]:
|
|
24
|
+
"""Run a desktop command without invoking a shell."""
|
|
25
|
+
return subprocess.run(
|
|
26
|
+
list(command),
|
|
27
|
+
check=False,
|
|
28
|
+
capture_output=True,
|
|
29
|
+
text=True,
|
|
30
|
+
timeout=timeout,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _tool_available(tool: str) -> bool:
|
|
35
|
+
return shutil.which(tool) is not None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def launch_app(application: str) -> str:
|
|
39
|
+
"""Launch a known desktop application by friendly name."""
|
|
40
|
+
name = application.strip().lower()
|
|
41
|
+
command = _ALLOWED_APPS.get(name)
|
|
42
|
+
if command is None:
|
|
43
|
+
return f"I cannot launch {application!r}; supported apps are terminal, browser, and file manager."
|
|
44
|
+
if not _tool_available(command[0]):
|
|
45
|
+
return f"Cannot launch {name}: required command {command[0]!r} is not installed."
|
|
46
|
+
try:
|
|
47
|
+
subprocess.Popen(list(command), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
48
|
+
except OSError as exc:
|
|
49
|
+
return f"Could not launch {name}: {exc}"
|
|
50
|
+
return f"Launched {name}."
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def adjust_volume(change: str) -> str:
|
|
54
|
+
"""Adjust ALSA master volume using amixer."""
|
|
55
|
+
if not _tool_available("amixer"):
|
|
56
|
+
return "Cannot adjust volume: amixer is not installed."
|
|
57
|
+
normalized = change.strip().lower()
|
|
58
|
+
if normalized in {"up", "increase", "louder", "+"}:
|
|
59
|
+
value = "5%+"
|
|
60
|
+
description = "increased"
|
|
61
|
+
elif normalized in {"down", "decrease", "quieter", "-"}:
|
|
62
|
+
value = "5%-"
|
|
63
|
+
description = "decreased"
|
|
64
|
+
elif normalized in {"mute", "muted"}:
|
|
65
|
+
value = "toggle"
|
|
66
|
+
description = "toggled"
|
|
67
|
+
else:
|
|
68
|
+
return "Volume parameter must be up, down, or mute."
|
|
69
|
+
try:
|
|
70
|
+
result = _run(("amixer", "-q", "sset", "Master", value))
|
|
71
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
72
|
+
return f"Volume adjustment failed: {exc}"
|
|
73
|
+
if result.returncode != 0:
|
|
74
|
+
detail = result.stderr.strip() or "amixer returned an error"
|
|
75
|
+
return f"Volume adjustment failed: {detail}"
|
|
76
|
+
return f"Volume {description}."
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def list_windows() -> str:
|
|
80
|
+
"""Return active window IDs and titles from xdotool."""
|
|
81
|
+
if not _tool_available("xdotool"):
|
|
82
|
+
return "Cannot list windows: xdotool is not installed."
|
|
83
|
+
try:
|
|
84
|
+
result = _run(("xdotool", "search", "--name", "."))
|
|
85
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
86
|
+
return f"Window listing failed: {exc}"
|
|
87
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
88
|
+
return "No active windows found."
|
|
89
|
+
windows: list[str] = []
|
|
90
|
+
for window_id in result.stdout.splitlines():
|
|
91
|
+
window_id = window_id.strip()
|
|
92
|
+
if not window_id:
|
|
93
|
+
continue
|
|
94
|
+
title_result = _run(("xdotool", "getwindowname", window_id))
|
|
95
|
+
title = title_result.stdout.strip() if title_result.returncode == 0 else "(untitled)"
|
|
96
|
+
windows.append(f"{window_id}: {title}")
|
|
97
|
+
return "Active windows:\n" + "\n".join(windows) if windows else "No active windows found."
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def manage_window(action: str, window_id: str) -> str:
|
|
101
|
+
"""Close or minimize one window by its xdotool ID."""
|
|
102
|
+
if not _tool_available("xdotool"):
|
|
103
|
+
return "Cannot manage windows: xdotool is not installed."
|
|
104
|
+
normalized = action.strip().lower()
|
|
105
|
+
command = {"close": "windowclose", "minimize": "windowminimize"}.get(normalized)
|
|
106
|
+
if command is None or not window_id.strip().isdigit():
|
|
107
|
+
return "Window action must be close or minimize, with a numeric window ID."
|
|
108
|
+
try:
|
|
109
|
+
result = _run(("xdotool", command, window_id.strip()))
|
|
110
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
111
|
+
return f"Window action failed: {exc}"
|
|
112
|
+
if result.returncode != 0:
|
|
113
|
+
return f"Window action failed: {result.stderr.strip() or 'xdotool returned an error'}"
|
|
114
|
+
return f"Window {window_id.strip()} {normalized}d."
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def system_status() -> str:
|
|
118
|
+
"""Return concise CPU, memory, and battery information."""
|
|
119
|
+
status: list[str] = []
|
|
120
|
+
if _tool_available("uptime"):
|
|
121
|
+
result = _run(("uptime",))
|
|
122
|
+
if result.returncode == 0:
|
|
123
|
+
status.append(f"Load: {result.stdout.strip()}")
|
|
124
|
+
try:
|
|
125
|
+
memory = _run(("free", "-h"))
|
|
126
|
+
memory_line = next((line for line in memory.stdout.splitlines() if line.startswith("Mem:")), None)
|
|
127
|
+
if memory_line:
|
|
128
|
+
fields = memory_line.split()
|
|
129
|
+
status.append(f"Memory: {fields[2]} used of {fields[1]}" if len(fields) > 2 else f"Memory: {memory_line}")
|
|
130
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
131
|
+
status.append("Memory: unavailable")
|
|
132
|
+
battery_path = "/sys/class/power_supply"
|
|
133
|
+
batteries = [entry for entry in os.listdir(battery_path)] if os.path.isdir(battery_path) else []
|
|
134
|
+
battery = next((entry for entry in batteries if entry.startswith("BAT")), None)
|
|
135
|
+
if battery:
|
|
136
|
+
capacity_file = os.path.join(battery_path, battery, "capacity")
|
|
137
|
+
try:
|
|
138
|
+
with open(capacity_file, encoding="ascii") as file:
|
|
139
|
+
status.append(f"Battery: {file.read().strip()}%")
|
|
140
|
+
except OSError:
|
|
141
|
+
status.append("Battery: unavailable")
|
|
142
|
+
else:
|
|
143
|
+
status.append("Battery: not detected")
|
|
144
|
+
return "System status:\n" + "\n".join(status)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def execute_system_command(command: str) -> str:
|
|
148
|
+
"""Execute a named safe system operation, never an arbitrary shell string."""
|
|
149
|
+
normalized = command.strip().lower()
|
|
150
|
+
if normalized in {"status", "system status", "info"}:
|
|
151
|
+
return system_status()
|
|
152
|
+
if normalized in {"windows", "list windows", "active windows"}:
|
|
153
|
+
return list_windows()
|
|
154
|
+
if normalized in {"volume up", "volume down", "volume mute"}:
|
|
155
|
+
return adjust_volume(normalized.removeprefix("volume "))
|
|
156
|
+
return "Unknown system command. Supported commands: status, windows, volume up, volume down, volume mute."
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "alinux"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Alinux AI orchestration shell for minimal Linux desktops"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Alinux contributors" }]
|
|
13
|
+
keywords = ["ai", "linux", "desktop", "automation", "fluxbox"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: End Users/Desktop",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Operating System :: POSIX :: Linux",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Topic :: Desktop Environment",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"openai>=1.40.0,<2",
|
|
25
|
+
"pydantic>=2.7.0,<3",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
dev = [
|
|
30
|
+
"build>=1.2.0",
|
|
31
|
+
"pytest>=8.0.0",
|
|
32
|
+
"ruff>=0.6.0",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
alinux = "alinux.main:main"
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["alinux"]
|
|
40
|
+
|
|
41
|
+
[tool.ruff]
|
|
42
|
+
target-version = "py310"
|
|
43
|
+
line-length = 100
|
|
44
|
+
|
|
45
|
+
[tool.pytest.ini_options]
|
|
46
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from alinux.agent import Agent
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_empty_input_returns_safe_response(monkeypatch):
|
|
5
|
+
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
|
6
|
+
|
|
7
|
+
result = Agent().decide(" ")
|
|
8
|
+
|
|
9
|
+
assert result.action == "respond_to_user"
|
|
10
|
+
assert "Please enter" in result.parameter
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_without_api_key_does_not_call_model(monkeypatch):
|
|
14
|
+
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
|
15
|
+
|
|
16
|
+
result = Agent().decide("show status")
|
|
17
|
+
|
|
18
|
+
assert result.action == "respond_to_user"
|
|
19
|
+
assert "OPENAI_API_KEY" in result.parameter
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from alinux.agent import Agent
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_groq_provider_selects_builtin_model_and_endpoint(monkeypatch):
|
|
5
|
+
monkeypatch.setenv("ALINUX_PROVIDER", "groq")
|
|
6
|
+
monkeypatch.setenv("ALINUX_API_KEY", "test-key")
|
|
7
|
+
|
|
8
|
+
agent = Agent()
|
|
9
|
+
|
|
10
|
+
assert agent.model == "llama-3.3-70b-versatile"
|
|
11
|
+
assert agent.base_url == "https://api.groq.com/openai/v1"
|
|
12
|
+
assert agent.api_key == "test-key"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_custom_openai_compatible_configuration(monkeypatch):
|
|
16
|
+
monkeypatch.delenv("ALINUX_PROVIDER", raising=False)
|
|
17
|
+
monkeypatch.setenv("ALINUX_API_KEY", "custom-key")
|
|
18
|
+
monkeypatch.setenv("ALINUX_API_BASE_URL", "https://example.test/v1")
|
|
19
|
+
|
|
20
|
+
agent = Agent(model="local-model")
|
|
21
|
+
|
|
22
|
+
assert agent.model == "local-model"
|
|
23
|
+
assert agent.base_url == "https://example.test/v1"
|
|
24
|
+
assert agent.api_key == "custom-key"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from alinux.fluxbox import install_fluxbox_menu
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_fluxbox_menu_entry_is_added_once(tmp_path: Path):
|
|
7
|
+
menu_path = tmp_path / "menu"
|
|
8
|
+
menu_path.write_text("[begin] (Fluxbox)\n[end]\n", encoding="utf-8")
|
|
9
|
+
|
|
10
|
+
install_fluxbox_menu(menu_path)
|
|
11
|
+
install_fluxbox_menu(menu_path)
|
|
12
|
+
|
|
13
|
+
content = menu_path.read_text(encoding="utf-8")
|
|
14
|
+
assert content.count("[exec] (Open Alinux) {alinux open}") == 1
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from alinux import system_controls
|
|
2
|
+
from alinux.agent import AgentAction
|
|
3
|
+
from alinux.main import _dispatch
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_arbitrary_system_commands_are_rejected():
|
|
7
|
+
result = system_controls.execute_system_command("rm -rf /")
|
|
8
|
+
|
|
9
|
+
assert result.startswith("Unknown system command")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_dispatch_responds_without_desktop_access():
|
|
13
|
+
result = _dispatch(AgentAction(action="respond_to_user", parameter="Hello"))
|
|
14
|
+
|
|
15
|
+
assert result == "Hello"
|