diff --git a/.github/PULL_REQUEST_TEMPLATE/feature.md b/.github/PULL_REQUEST_TEMPLATE/feature.md new file mode 100644 index 0000000..e5550cf --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/feature.md @@ -0,0 +1,29 @@ +# Feature: `` + +## Summary +> What this PR adds and why. + +## Related Issue +Closes # + +## Changes +### What's new +- + +### Implementation notes +> Key design decisions, tradeoffs, or anything a reviewer should know before reading the diff. +- + +## Checklist +- [ ] Conventional Commits used for all commits +- [ ] Tests added/updated for new behavior +- [ ] Docs/README updated if public API changed +- [ ] No unrelated changes bundled in this PR + +## Out of Scope +> Anything deliberately left out (tracked in a separate issue, if applicable). +- + +## Links +- Issue: # +- Docs: \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE/release.md b/.github/PULL_REQUEST_TEMPLATE/release.md new file mode 100644 index 0000000..86748fd --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/release.md @@ -0,0 +1,33 @@ +# Release `vX.Y.Z` + +## Summary + +> What changed and why. + +## Changes + +### New features +- + +### Bug fixes +- + +### Breaking changes +- + +## Checklist + +- [ ] Conventional Commits verified on `main` +- [ ] `release-config.json` / `.release-manifest.json` up to date +- [ ] Tests passing on mGBA +- [ ] `pipx run build` clean locally +- [ ] **Cartridge App** workflow green on `stable` + +## Rollback + +> If broken post-merge: publish a patch `vX.Y.Z+1` and yank on PyPI. + +## Links + +- Issue: # +- Docs: \ No newline at end of file diff --git a/.github/policy_commit.md b/.github/policy_commit.md new file mode 100644 index 0000000..eb513f5 --- /dev/null +++ b/.github/policy_commit.md @@ -0,0 +1,48 @@ +# Policy de commit + +## Format + +``` +(): +``` + +- **type** : obligatoire, voir liste ci-dessous +- **scope** : optionnel, module/partie concernée (ex: `cli`, `ci`, `bot`) +- **description** : à l'impératif, minuscule, pas de point final + +## Types + +- `feat!` : nouvelle fonctionnalité +- `fix` : correction de bug +- `refactor` : changement de code sans impact fonctionnel +- `docs` : documentation uniquement +- `test` : ajout/modif de tests +- `ci` : pipeline CI/CD + +## Règles + +1. Une ligne de résumé (72 caractères max) +2. Un commit = un changement logique +3. Description claire, pas de "fix stuff" ou "update" +4. Corps du message (optionnel) séparé par une ligne vide, explique le *pourquoi* +5. Pas d'emoji, pas de ponctuation superflue + +## Exemples + +``` +feat(cli): add boot animation with rich progress bar +fix(ci): correct rsync path in sync workflow +refactor(bot): extract command handlers into separate module +docs: add setup instructions for devenv +chore: bump python dependencies +``` + +## Breaking changes + +Ajouter `!` après le type/scope et expliquer dans le corps : + +``` +feat!: change config format from json to toml + +Migration nécessaire, voir CHANGELOG.md +``` \ No newline at end of file diff --git a/.github/workflows/LaMira.yml b/.github/workflows/LaMira.yml index 4daa19f..d0bf42d 100644 --- a/.github/workflows/LaMira.yml +++ b/.github/workflows/LaMira.yml @@ -16,7 +16,6 @@ env: jobs: check_work: runs-on: ubuntu-latest - if: github.repository != 'Project-Tranquility/cli' name: check_work container: image: python:3.14 @@ -30,7 +29,7 @@ jobs: mira: runs-on: ubuntu-latest needs: check_work - if: github.repository != 'Project-Tranquility/Tranquillity' && github.repository != 'Project-Tranquility/cli' && github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.check_work.result == 'success' + if: github.repository != 'Project-Tranquility/Tranquillity' && github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.check_work.result == 'success' steps: - uses: actions/checkout@v6 with: diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd7d347 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Devenv +.devenv* +devenv.local.nix +devenv.local.yaml + +# direnv +.direnv + +# pre-commit +.pre-commit-config.yaml +.vscode + +__pycache__ +.env + +.spotify_token.json \ No newline at end of file diff --git a/README.md b/README.md index fafb666..0719210 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,3 @@ -# cli -cli for Tranquility +# ctOS +ctOS ? +This is Central Tranquillity Operating System, the cli for Tranquillity diff --git a/ctOS.py b/ctOS.py new file mode 100644 index 0000000..b41f4f4 --- /dev/null +++ b/ctOS.py @@ -0,0 +1,26 @@ +from test import demo +from src.start.start import start +from src.help import help +import sys +from dotenv import load_dotenv +from src.music.music import music_command + +load_dotenv() + +def main(): + nb = len(sys.argv) + + if nb <= 1: + help() + return + if sys.argv[1] == "--music": + music_command() + return + if sys.argv[1] == "--help" or "-h": + help() + if sys.argv[1] == "hello": + demo() + start() + else: + return 84 +main() \ No newline at end of file diff --git a/devenv.lock b/devenv.lock new file mode 100644 index 0000000..91bb64d --- /dev/null +++ b/devenv.lock @@ -0,0 +1,65 @@ +{ + "nodes": { + "devenv": { + "locked": { + "dir": "src/modules", + "lastModified": 1782751734, + "narHash": "sha256-shLdS1fYdEQvbp+XzzSDq/pBxKoniU+cqXvDFgmEE70=", + "owner": "cachix", + "repo": "devenv", + "rev": "26c8d030f9398fb8531a2b19f6b3aaa8b3589b47", + "type": "github" + }, + "original": { + "dir": "src/modules", + "owner": "cachix", + "repo": "devenv", + "type": "github" + } + }, + "nixpkgs": { + "inputs": { + "nixpkgs-src": "nixpkgs-src" + }, + "locked": { + "lastModified": 1782132010, + "narHash": "sha256-ZnAVHdVrotp80iIMm5CSR1fdxPlw7Uwmwxb+O/wsgZ8=", + "owner": "cachix", + "repo": "devenv-nixpkgs", + "rev": "12866ae2dddbc0ab8b329915f8072bb9c75bde89", + "type": "github" + }, + "original": { + "owner": "cachix", + "ref": "rolling", + "repo": "devenv-nixpkgs", + "type": "github" + } + }, + "nixpkgs-src": { + "flake": false, + "locked": { + "lastModified": 1781607440, + "narHash": "sha256-rxO+uc/KFbSJp+pgyXRuAX6QlG9hJdnt0BXpEQRXY+U=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "3e41b24abd260e8f71dbe2f5737d24122f972158", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "devenv": "devenv", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} \ No newline at end of file diff --git a/devenv.nix b/devenv.nix new file mode 100644 index 0000000..4c14513 --- /dev/null +++ b/devenv.nix @@ -0,0 +1,52 @@ +{ pkgs, lib, config, inputs, ... }: + +{ + # https://devenv.sh/basics/ + env.GREET = "devenv"; + + # https://devenv.sh/packages/ + packages = [ pkgs.git ]; + + # https://devenv.sh/languages/ + # languages.rust.enable = true; + + # https://devenv.sh/processes/ + # processes.dev.exec = "${lib.getExe pkgs.watchexec} -n -- ls -la"; + + # https://devenv.sh/services/ + # services.postgres.enable = true; + + # https://devenv.sh/scripts/ + scripts.hello.exec = '' + echo hello from $GREET + ''; + + # https://devenv.sh/basics/ + enterShell = '' + hello # Run scripts directly + git --version # Use packages + ''; + + # https://devenv.sh/tasks/ + # tasks = { + # "myproj:setup".exec = "mytool build"; + # "devenv:enterShell".after = [ "myproj:setup" ]; + # }; + + languages.python = { + enable = true; + venv.enable = true; + venv.requirements = ./requirement.txt; + }; + + # https://devenv.sh/tests/ + enterTest = '' + echo "Running tests" + git --version | grep --color=auto "${pkgs.git.version}" + ''; + + # https://devenv.sh/git-hooks/ + # git-hooks.hooks.shellcheck.enable = true; + + # See full reference at https://devenv.sh/reference/options/ +} diff --git a/devenv.yaml b/devenv.yaml new file mode 100644 index 0000000..3dcab58 --- /dev/null +++ b/devenv.yaml @@ -0,0 +1,18 @@ +# yaml-language-server: $schema=https://devenv.sh/devenv.schema.json +inputs: + nixpkgs: + url: github:cachix/devenv-nixpkgs/rolling + +# If you're using non-OSS software, you can set allow_unfree to true. +# allow_unfree: true + +# If you're not willing to allow unsupported packages: +# allow_unsupported_system: false + +# If you're willing to use a package that's vulnerable +# permitted_insecure_packages: +# - "openssl-1.1.1w" + +# If you have more than one devenv you can merge them +#imports: +# - ./backend diff --git a/requirement.txt b/requirement.txt new file mode 100644 index 0000000..1e58b25 --- /dev/null +++ b/requirement.txt @@ -0,0 +1,3 @@ +requests +rich +python-dotenv \ No newline at end of file diff --git a/src/help.py b/src/help.py new file mode 100644 index 0000000..7754ea6 --- /dev/null +++ b/src/help.py @@ -0,0 +1,7 @@ +def help(): + print("Welcome to ctOS, the cli for Tranquillity\n") + print("Usage : ctOS [command]") + print(" ctOS [flags]\n") + print("[command] :\n") + print(" --light toggle (toggle connected light)") + print(" --music (use command for music)") \ No newline at end of file diff --git a/src/light/light.py b/src/light/light.py new file mode 100644 index 0000000..e9fbb57 --- /dev/null +++ b/src/light/light.py @@ -0,0 +1,15 @@ +import os +import sys +from src.light.toggle_light import toggle_light + +def light_command(): + nb = len(sys.argv) + + if nb <= 2: + print("no argument give. Please give word") + return + if sys.argv[2] == "toggle": + toggle_light() + return + else: + print("no argument give. Please give word") \ No newline at end of file diff --git a/src/light/toggle_light.py b/src/light/toggle_light.py new file mode 100644 index 0000000..75e45c0 --- /dev/null +++ b/src/light/toggle_light.py @@ -0,0 +1,18 @@ +import os +import subprocess + +FIFO_PATH = "/tmp/kitty_shell_input" +CMD_LIGHT = "./ecc-api --toggle" + +def toggle_light(): + if not os.path.exists(FIFO_PATH): + print(f"Sub-Agent: FIFO introuvable: {FIFO_PATH}") + return + with open(FIFO_PATH, "w") as fifo: + fifo.write(CMD_LIGHT + "\n") + subprocess.run(CMD_LIGHT) + return + +def toggle_light(): + subprocess.run(CMD_LIGHT) + return \ No newline at end of file diff --git a/src/music/get_id_device.py b/src/music/get_id_device.py new file mode 100644 index 0000000..d7a974a --- /dev/null +++ b/src/music/get_id_device.py @@ -0,0 +1,17 @@ +import requests +import json +import sys +from src.music.get_token_tmp import get_token +import requests + +def get_id_device(): + access_token = get_token() + url = "https://api.spotify.com/v1/me/player/devices" + header = {"Authorization": "Bearer " + access_token} + + result = requests.get(url, headers=header) + try: + body = result.json() + except Exception: + body = result.text + print(body) diff --git a/src/music/get_token_tmp.py b/src/music/get_token_tmp.py new file mode 100644 index 0000000..193fc4f --- /dev/null +++ b/src/music/get_token_tmp.py @@ -0,0 +1,115 @@ +import os +import json +import base64 +import webbrowser +from http.server import HTTPServer, BaseHTTPRequestHandler +from urllib.parse import urlencode, urlparse, parse_qs +from requests import post +from dotenv import load_dotenv + +load_dotenv() + +CLIENT_ID = os.getenv("TOKEN_PUBLIC") +CLIENT_SECRET = os.getenv("TOKEN_PRIVATE") +REDIRECT_URI = "http://127.0.0.1:8888/callback" +SCOPES = "user-read-playback-state user-modify-playback-state" +TOKEN_FILE = os.path.join(os.path.dirname(__file__), ".spotify_token.json") + + +def _basic_auth_header(): + auth_string = f"{CLIENT_ID}:{CLIENT_SECRET}".encode("utf-8") + return "Basic " + base64.b64encode(auth_string).decode("utf-8") + + +class _CallbackHandler(BaseHTTPRequestHandler): + code = None + + def do_GET(self): + query = parse_qs(urlparse(self.path).query) + _CallbackHandler.code = query.get("code", [None])[0] + self.send_response(200) + self.send_header("Content-type", "text/html") + self.end_headers() + self.wfile.write(b"

You can close this.

") + + def log_message(self, format, *args): + pass + + +def _request_authorization_code(): + params = { + "client_id": CLIENT_ID, + "response_type": "code", + "redirect_uri": REDIRECT_URI, + "scope": SCOPES, + } + auth_url = "https://accounts.spotify.com/authorize?" + urlencode(params) + webbrowser.open(auth_url) + + server = HTTPServer(("127.0.0.1", 8888), _CallbackHandler) + server.handle_request() + return _CallbackHandler.code + + +def _exchange_code_for_tokens(code): + url = "https://accounts.spotify.com/api/token" + headers = { + "Authorization": _basic_auth_header(), + "Content-Type": "application/x-www-form-urlencoded", + } + data = { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": REDIRECT_URI, + } + result = post(url, headers=headers, data=data) + result.raise_for_status() + return result.json() + + +def _refresh_access_token(refresh_token): + url = "https://accounts.spotify.com/api/token" + headers = { + "Authorization": _basic_auth_header(), + "Content-Type": "application/x-www-form-urlencoded", + } + data = { + "grant_type": "refresh_token", + "refresh_token": refresh_token, + } + result = post(url, headers=headers, data=data) + result.raise_for_status() + return result.json() + + +def _save_tokens(tokens): + with open(TOKEN_FILE, "w") as f: + json.dump(tokens, f) + + +def _load_tokens(): + if not os.path.exists(TOKEN_FILE): + return None + with open(TOKEN_FILE) as f: + return json.load(f) + + +def get_token(): + if not CLIENT_ID or not CLIENT_SECRET: + raise RuntimeError("TOKEN_PUBLIC et TOKEN_PRIVATE doivent etre definis dans l'environnement") + + tokens = _load_tokens() + + if tokens and "refresh_token" in tokens: + new_tokens = _refresh_access_token(tokens["refresh_token"]) + new_tokens.setdefault("refresh_token", tokens["refresh_token"]) + _save_tokens(new_tokens) + return new_tokens["access_token"] + + code = _request_authorization_code() + if not code: + raise RuntimeError("Aucun code recu, autorisation echouee") + + tokens = _exchange_code_for_tokens(code) + _save_tokens(tokens) + return tokens["access_token"] \ No newline at end of file diff --git a/src/music/music.py b/src/music/music.py new file mode 100644 index 0000000..f4705bb --- /dev/null +++ b/src/music/music.py @@ -0,0 +1,26 @@ +import requests +import json +import sys +from src.music.get_token_tmp import get_token +import requests +from src.music.get_id_device import get_id_device +from src.music.pause import pause +from src.music.resume import resume + +def music_command(): + nb = len(sys.argv) + + if nb <= 2: + print("no argument give. Please give word") + return + if sys.argv[2] == "device": + get_id_device() + return + if sys.argv[2] == "pause": + pause() + return + if sys.argv[2] == "resume": + resume() + return + else: + print("no argument give. Please give word") diff --git a/src/music/pause.py b/src/music/pause.py new file mode 100644 index 0000000..abd6c88 --- /dev/null +++ b/src/music/pause.py @@ -0,0 +1,15 @@ +import requests +from src.music.get_token_tmp import get_token + +def pause(): + access_token = get_token() + url = "https://api.spotify.com/v1/me/player/pause" + header = {"Authorization": "Bearer " + access_token} + + result = requests.put(url, headers=header) + try: + body = result.json() + except Exception: + body = result.text + print(body) + print(result.status_code) \ No newline at end of file diff --git a/src/music/resume.py b/src/music/resume.py new file mode 100644 index 0000000..d09bcf8 --- /dev/null +++ b/src/music/resume.py @@ -0,0 +1,15 @@ +import requests +from src.music.get_token_tmp import get_token + +def resume(): + access_token = get_token() + url = "https://api.spotify.com/v1/me/player/play" + header = {"Authorization": "Bearer " + access_token, "Content-Type": "application/json"} + data = {} + result = requests.put(url, headers=header, json=data) + try: + body = result.json() + except Exception: + body = result.text + print(body) + print(result.status_code) \ No newline at end of file diff --git a/src/start/start.py b/src/start/start.py new file mode 100644 index 0000000..3bcf457 --- /dev/null +++ b/src/start/start.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +import requests +import time +import random +from collections import deque +from rich.console import Console +from rich.layout import Layout +from rich.live import Live +from rich.text import Text +from rich.align import Align +from rich.panel import Panel + +from test import demo + +console = Console() +GREEN = "bold spring_green2" +DIM_GREEN = "spring_green4" + +BOOT_LINES = [ + "[INIT] Loading all module...", + "[NET ] Establishing uplink to backbone...", + "[OK ] All systems are onlines.", +] + +SPACE_LINE = [ + " " +] + +WAITING_LINE = [ + "." + ".." + "..." +] + +START_LINE = [ + "Hello from cpOS" +] + +def make_layout() -> Layout: + layout = Layout() + layout.split_column( + Layout(name="main", ratio=2), + ) + return layout + +def render_status(history: deque) -> Panel: + body = Text("\n".join(history), style=DIM_GREEN) + return Panel(body, border_style=DIM_GREEN) + +def start(): + layout = make_layout() + history = deque(maxlen=6) + messages = BOOT_LINES + start = START_LINE + wait = WAITING_LINE + is_finish = False + + with Live(layout, console=console, refresh_per_second=20, screen=True): + while is_finish is False: + history = None + history = deque(maxlen=6) + history.append(start[0]) + layout["main"].update(render_status(history)) \ No newline at end of file diff --git a/test.py b/test.py new file mode 100644 index 0000000..55c804b --- /dev/null +++ b/test.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +import time +import random +from collections import deque +from rich.console import Console +from rich.layout import Layout +from rich.live import Live +from rich.text import Text +from rich.align import Align +from rich.panel import Panel + +console = Console() +GREEN = "bold spring_green2" +DIM_GREEN = "spring_green4" + +BOOT_LINES = [ + "[INIT] Loading all module...", + "[NET ] Establishing uplink to backbone...", + "[OK ] All systems are onlines.", +] + +SPACE_LINE = [ + " " +] + +WAITING_LINE = [ + "." + ".." + "..." +] + +START_LINE = [ + "Hello from cpOS" +] + +def make_layout() -> Layout: + layout = Layout() + layout.split_column( + Layout(name="main", ratio=2), + Layout(name="bottom", size=9), + ) + layout["bottom"].split_row( + Layout(name="bottom_left"), + ) + return layout + +def render_center(percent: int, width: int = 40) -> Align: + filled = int(width * percent / 100) + empty = width - filled + bar_str = "█" * filled + "░" * empty + text = Text(f"{bar_str} {percent:3d}%", style=GREEN, justify="center") + return Align.center(text, vertical="middle") + +def render_status(history: deque) -> Panel: + body = Text("\n".join(history), style=DIM_GREEN) + return Panel(body, border_style=DIM_GREEN) + +def demo(): + layout = make_layout() + history = deque(maxlen=6) + messages = BOOT_LINES + start = START_LINE + wait = WAITING_LINE + nb_task = 0 + i = 0 + is_finish = False + + + with Live(layout, console=console, refresh_per_second=20, screen=True): + while is_finish is False: + i = i + 1 + percent = (i * 100 // 59) + layout["main"].update(render_center(percent)) + + if i % 5 == 0: + history.append(random.choice(messages) + f" {random.randint(0,99)}%") + nb_task = nb_task + 1 + layout["bottom_left"].update(render_status(history)) + + if (percent >= 100): + is_finish = True + time.sleep(0.08) + + +if __name__ == "__main__": + demo() \ No newline at end of file