diff --git a/.env b/.env new file mode 100644 index 00000000..3dc383c8 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +export OPENAI_API_KEY=$(rbw get openai-api-key) diff --git a/.env.template b/.env.template new file mode 100644 index 00000000..3dc383c8 --- /dev/null +++ b/.env.template @@ -0,0 +1 @@ +export OPENAI_API_KEY=$(rbw get openai-api-key) diff --git a/.envrc b/.envrc index 1530103c..b0d10d7c 100644 --- a/.envrc +++ b/.envrc @@ -4,6 +4,10 @@ fi watch_file .direnv/selected-shell +if [ -e .env ]; then + source .env +fi + if [ -e .direnv/selected-shell ]; then use flake .#$(cat .direnv/selected-shell) else diff --git a/pkgs/clan-cli/clan_cli/__init__.py b/pkgs/clan-cli/clan_cli/__init__.py index 2e29a000..b22ea926 100644 --- a/pkgs/clan-cli/clan_cli/__init__.py +++ b/pkgs/clan-cli/clan_cli/__init__.py @@ -6,6 +6,8 @@ from pathlib import Path from types import ModuleType from typing import Any +from clan_cli import clana + from . import backups, config, facts, flakes, flash, history, machines, secrets, vms from .custom_logger import setup_logging from .dirs import get_clan_flake_toplevel @@ -110,6 +112,11 @@ def create_parser(prog: str | None = None) -> argparse.ArgumentParser: ) flash.register_parser(parser_flash) + parser_clana = subparsers.add_parser( + "clana", help="Describe a VM with natural language and launch it" + ) + clana.register_parser(parser_clana) + if argcomplete: argcomplete.autocomplete(parser) diff --git a/pkgs/clan-cli/clan_cli/clan_openai.py b/pkgs/clan-cli/clan_cli/clan_openai.py new file mode 100644 index 00000000..bb1b26b0 --- /dev/null +++ b/pkgs/clan-cli/clan_cli/clan_openai.py @@ -0,0 +1,51 @@ +import json +import os +import urllib.request +from typing import Any + +# Your OpenAI API key +api_key: str = os.environ["OPENAI_API_KEY"] + +# The URL to which the request is sent +url: str = "https://api.openai.com/v1/chat/completions" + +# The header includes the content type and the authorization with your API key +headers: dict[str, str] = { + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}", +} + + +def complete( + messages: list[dict[str, Any]], + model: str = "gpt-3.5-turbo", + temperature: float = 1.0, +) -> str: + # Data to be sent in the request + data = { + "model": model, + "messages": messages, + "temperature": temperature, + } + + # Create a request object with the URL and the headers + req = urllib.request.Request(url, json.dumps(data).encode("utf-8"), headers) + + # Make the request and read the response + with urllib.request.urlopen(req) as response: + response_body = response.read() + resp_data = json.loads(response_body) + return resp_data["choices"][0]["message"]["content"] + + +def complete_prompt( + prompt: str, + system: str = "", + model: str = "gpt-3.5-turbo", + temperature: float = 1.0, +) -> str: + return complete( + [{"role": "system", "content": system}, {"role": "user", "content": prompt}], + model, + temperature, + ) diff --git a/pkgs/clan-cli/clan_cli/clana/__init__.py b/pkgs/clan-cli/clan_cli/clana/__init__.py new file mode 100644 index 00000000..ba5dcba2 --- /dev/null +++ b/pkgs/clan-cli/clan_cli/clana/__init__.py @@ -0,0 +1,114 @@ +# !/usr/bin/env python3 +# A subcommand that interfaces with openai to generate nixos configurations and launches VMs with them. +# The `clan clana` command allows the user to enter a prompt with the wishes for the VM and then generates a nixos configuration and launches a VM with it. +# for now this POC should be stateless. A configuration.nix should be generated ina temporary directory and directly launched. +# there should be no additional arguments. +# THe prompt is read from stdin + +import argparse +import os +from pathlib import Path + +from clan_cli import clan_openai +from clan_cli.errors import ClanCmdError +from clan_cli.vms.run import run_command + +base_config = Path(__file__).parent.joinpath("base-config.nix").read_text() + +system_msg = f""" + Your name is clana, an assistant for creating NixOS configurations. + Your task is to generate a NixOS configuration.nix file. + Do not output any explanations or comments, not even when the user asks a question or provides feedback. + Always provide only the content of the configuration.nix file. + Don't use any nixos options for which you are not sure about their syntax. + Generate a configuration.nix which has a very high probability of just working. + The user who provides the prompt might have technical expertise, or none at all. + Even a grandmother who has no idea about computers should be able to use this. + Translate the users requirements to a working configuration.nix file. + Don't set any options under `nix.`. + The user should not have a password and log in automatically. + + Take care specifically about: + - specify every option only once within the same file. Otherwise it will lead to an error like this: error: attribute 'environment.systemPackages' already defined at [...]/configuration.nix:X:X + - don't set a password for the user. it's already set in the base config + + + Assume the following base config is already imported. Any option set in there is already configured and doesn't need to be specified anymore: + + ```nix + {base_config} + ``` + + The base config will be imported by the system. No need to import it anymore. +""" + + +# takes a (sub)parser and configures it +def register_parser(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--show", action="store_true", help="show the configuration") + parser.set_defaults(func=clana_command) + + +def clana_command(args: argparse.Namespace) -> None: + print("Please enter your wishes for the new computer: ") + prompt = input() + # prompt = "I want to email my grandchildren and watch them on facebook" + print("Thank you. Generating your computer...") + # config = clan_openai.complete(messages, model="gpt-4-turbo-preview").strip() + config = Path(".direnv/configuration.nix").read_text() + messages = [ + {"role": "system", "content": system_msg}, + {"role": "user", "content": prompt}, + ] + conf_dir = Path("/tmp/clana") + conf_dir.mkdir(exist_ok=True) + for f in conf_dir.iterdir(): + f.unlink() + (conf_dir / "flake.nix").write_bytes( + Path(__file__).parent.joinpath("flake.nix.template").read_bytes() + ) + with open(conf_dir / "base-config.nix", "w") as f: + f.write(base_config) + with open(conf_dir / "hardware-configuration.nix", "w") as f: + f.write("{}") + with open(conf_dir / "configuration.nix", "w") as f: + f.write( + """ + { + imports = [ + ./base-config.nix + ./ai-config.nix + ]; + } + """ + ) + while True: + config_orig = clan_openai.complete( + messages, model="gpt-4-turbo-preview" + ).strip() + # remove code blocks + lines = config_orig.split("\n") + if lines[0].startswith("```"): + lines = lines[1:-1] + config = "\n".join(lines) + if args.show: + print("Configuration generated:") + print(config) + print("Configuration generated. Launching...") + with open(conf_dir / "ai-config.nix", "w") as f: + f.write(config) + + os.environ["NIXPKGS_ALLOW_UNFREE"] = "1" + try: + run_command( + machine="clana-machine", flake=conf_dir, nix_options=["--impure"] + ) + break + except ClanCmdError as e: + messages += [ + {"role": "assistant", "content": config_orig}, + { + "role": "system", + "content": f"There was a problem that needs to be fixed:\n{e.cmd.stderr}", + }, + ] diff --git a/pkgs/clan-cli/clan_cli/clana/base-config.nix b/pkgs/clan-cli/clan_cli/clana/base-config.nix new file mode 100644 index 00000000..89736b14 --- /dev/null +++ b/pkgs/clan-cli/clan_cli/clana/base-config.nix @@ -0,0 +1,60 @@ +{ config, ... }: + +{ + imports = + [ + # Include the results of the hardware scan. + ./hardware-configuration.nix + ]; + + # Ensure that software properties (e.g., being unfree) are respected. + nixpkgs.config = { + allowUnfree = true; + }; + + # Use the systemd-boot EFI boot loader. + boot.loader.systemd-boot.enable = true; + boot.loader.efi.canTouchEfiVariables = true; + + networking.hostName = "clana"; # Define your hostname. + networking.networkmanager.enable = true; + + # Enable the X11 windowing system. + services.xserver.enable = true; + services.xserver.layout = "us"; + services.xserver.xkbOptions = "eurosign:e"; + + # Enable touchpad support. + services.xserver.libinput.enable = true; + + # Enable the KDE Desktop Environment. + services.xserver.displayManager.sddm.enable = true; + services.xserver.desktopManager.plasma5.enable = true; + + # Enable sound. + sound.enable = true; + hardware.pulseaudio.enable = true; + + # Autologin settings. + services.xserver.displayManager.autoLogin.enable = true; + services.xserver.displayManager.autoLogin.user = "user"; + + # User settings. + users.users.user = { + isNormalUser = true; + extraGroups = [ "wheel" ]; # Enable sudo for the user. + uid = 1000; + password = "hello"; + openssh.authorizedKeys.keys = [ ]; + }; + + # Enable firewall. + networking.firewall.enable = true; + networking.firewall.allowedTCPPorts = [ 80 443 ]; # HTTP and HTTPS + + # Set time zone. + time.timeZone = "UTC"; + + # System-wide settings. + system.stateVersion = "22.05"; # Edit this to your NixOS release version. +} diff --git a/pkgs/clan-cli/clan_cli/clana/flake.nix.template b/pkgs/clan-cli/clan_cli/clana/flake.nix.template new file mode 100644 index 00000000..ab9fc486 --- /dev/null +++ b/pkgs/clan-cli/clan_cli/clana/flake.nix.template @@ -0,0 +1,30 @@ +{ + description = ""; + + inputs.clan-core.url = "git+https://git.clan.lol/clan/clan-core"; + + outputs = { self, clan-core, ... }: + let + system = "x86_64-linux"; + pkgs = clan-core.inputs.nixpkgs.legacyPackages.${system}; + clan = clan-core.lib.buildClan { + directory = self; + clanName = "clana-clan"; + machines.clana-machine = { + imports = [ + ./configuration.nix + ]; + }; + }; + in + { + # all machines managed by cLAN + inherit (clan) nixosConfigurations clanInternals; + # add the cLAN cli tool to the dev shell + devShells.${system}.default = pkgs.mkShell { + packages = [ + clan-core.packages.${system}.clan-cli + ]; + }; + }; +} diff --git a/pkgs/clan-cli/clan_cli/vms/run.py b/pkgs/clan-cli/clan_cli/vms/run.py index f58db06c..7d189b53 100644 --- a/pkgs/clan-cli/clan_cli/vms/run.py +++ b/pkgs/clan-cli/clan_cli/vms/run.py @@ -3,7 +3,6 @@ import importlib import json import logging import os -from dataclasses import dataclass, field from pathlib import Path from tempfile import TemporaryDirectory @@ -177,28 +176,20 @@ def run_vm(vm: VmConfig, nix_options: list[str] = []) -> None: ) -@dataclass -class RunOptions: - machine: str - flake: Path - nix_options: list[str] = field(default_factory=list) - waypipe: bool = False +def run_command( + machine: str, flake: Path, option: list[str] = [], **args: argparse.Namespace +) -> None: + machine_obj: Machine = Machine(machine, flake) + + vm: VmConfig = inspect_vm(machine=machine_obj) + + run_vm(vm, option) -def run_command(args: argparse.Namespace) -> None: - run_options = RunOptions( - machine=args.machine, - flake=args.flake, - nix_options=args.option, - ) - - machine = Machine(run_options.machine, run_options.flake) - - vm = inspect_vm(machine=machine) - - run_vm(vm, run_options.nix_options) +def _run_command(args: argparse.Namespace) -> None: + run_command(**args.vars()) def register_run_parser(parser: argparse.ArgumentParser) -> None: parser.add_argument("machine", type=str, help="machine in the flake to run") - parser.set_defaults(func=run_command) + parser.set_defaults(func=_run_command)