Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
1b2ead2
feat: create CLI
faretek1 Oct 4, 2025
6ae371d
feat: sqlite db
faretek1 Oct 4, 2025
a248b7e
feat: login
faretek1 Oct 4, 2025
28b5876
feat: init session table
faretek1 Oct 4, 2025
d4ba212
feat: save login data to table
faretek1 Oct 4, 2025
b1196ab
fix: allow updating credentials
faretek1 Oct 4, 2025
5e14d85
feat: login by sessid
faretek1 Oct 4, 2025
3903926
fix: remove note from init
faretek1 Oct 4, 2025
c3e96aa
feat: Groups tables
faretek1 Oct 4, 2025
afa4875
feat: add col
faretek1 Oct 4, 2025
c8cd57b
feat: print current group
faretek1 Oct 4, 2025
9ef6015
feat: rich print group
faretek1 Oct 5, 2025
7c005d5
feat: make group for new user
faretek1 Oct 5, 2025
69b4618
feat: group list
faretek1 Oct 5, 2025
3477ba0
feat: enable rich traceback
faretek1 Oct 5, 2025
aad2b01
feat: add user to new automatic group
faretek1 Oct 5, 2025
571e9f9
feat: add user to current group
faretek1 Oct 5, 2025
2f33dcf
feat: new group
faretek1 Oct 5, 2025
3d94345
feat: switch group
faretek1 Oct 5, 2025
4961ede
feat: add to group
faretek1 Oct 5, 2025
9ed0ce0
feat: remove from group
faretek1 Oct 5, 2025
1992a7b
feat: remove format_esc
faretek1 Oct 5, 2025
8929924
feat: view profile
faretek1 Oct 5, 2025
1e4500a
feat: view user
faretek1 Oct 5, 2025
59979b4
fix: use markup escape, not console escape
faretek1 Oct 5, 2025
7aa31c6
fix: get classroom for banned users
faretek1 Oct 5, 2025
8cfbcbd
feat: display featured project
faretek1 Oct 5, 2025
58683cb
fix: vertical truncation
faretek1 Oct 5, 2025
4b2cfc3
feat: -S studio command
faretek1 Oct 5, 2025
6112a4b
feat: -P project command
faretek1 Oct 5, 2025
5cafc34
feat: print user pfp
faretek1 Oct 5, 2025
7f8f669
feat: print studio image
faretek1 Oct 5, 2025
9c746a1
feat: print project image
faretek1 Oct 5, 2025
0dc55ab
chore: sync cli with main (#520)
faretek1 Oct 9, 2025
c4e38a3
feat: delete group
faretek1 Oct 11, 2025
169e0f8
feat: copy group
faretek1 Oct 11, 2025
abff5ea
Merge remote-tracking branch 'origin/cli' into cli
faretek1 Oct 11, 2025
ad401ef
fix: switch group on deletion, and don't delete last group
faretek1 Oct 11, 2025
2d8a1c8
feat: group rename
faretek1 Oct 11, 2025
f780278
feat: list sessions
faretek1 Oct 11, 2025
6dc32d4
feat: print session by name
faretek1 Oct 11, 2025
32b2588
Merge branch 'main' into cli
faretek1 Oct 11, 2025
ae7585d
feat: add password column to sessions
faretek1 Oct 11, 2025
4f32543
feat: login with password saves password
faretek1 Oct 11, 2025
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
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ SimpleWebSocketServer
typing-extensions
browser_cookie3
aiohttp
rich
# install rich-pixels if you want CLI image support. This is an optional dependency.
93 changes: 93 additions & 0 deletions scratchattach/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""
Scratchattach CLI. Most source code is in the `cli` directory
"""

import argparse

from scratchattach import cli
from scratchattach.cli import db, cmd
from scratchattach.cli.context import ctx, console

import rich.traceback

rich.traceback.install()


# noinspection PyUnusedLocal
def main():
parser = argparse.ArgumentParser(
prog="scratch",
description="Scratchattach CLI",
epilog=f"Running scratchattach CLI version {cli.VERSION}",
)

# Using walrus operator & ifs for artificial indentation
if commands := parser.add_subparsers(dest="command"):
commands.add_parser("profile", help="View your profile")
commands.add_parser("sessions", help="View session list")
if login := commands.add_parser("login", help="Login to Scratch"):
login.add_argument("--sessid", dest="sessid", nargs="?", default=False, const=True,
help="Login by session ID")
if group := commands.add_parser("group", help="View current session group"):
if group_commands := group.add_subparsers(dest="group_command"):
group_commands.add_parser("list", help="List all session groups")
group_commands.add_parser("add", help="Add sessions to group")
group_commands.add_parser("remove", help="Remove sessions from a group")
group_commands.add_parser("delete", help="Delete current group")
if group_copy := group_commands.add_parser("copy", help="Copy current group with a new name"):
group_copy.add_argument("group_name", help="New group name")
if group_rename := group_commands.add_parser("rename", help="Rename current group"):
group_rename.add_argument("group_name", help="New group name")
if group_new := group_commands.add_parser("new", help="Create a new group"):
group_new.add_argument("group_name")
if group_switch := group_commands.add_parser("switch", help="Change the current group"):
group_switch.add_argument("group_name")

parser.add_argument("-U", "--username", dest="username", help="Name of user to look at")
parser.add_argument("-P", "--project", dest="project_id", help="ID of project to look at")
parser.add_argument("-S", "--studio", dest="studio_id", help="ID of studio to look at")
parser.add_argument("-L", "--session_name", dest="session_name",
help="Name of (registered) session/login to look at")

args = parser.parse_args(namespace=cli.ArgSpace())
cli.ctx.args = args
cli.ctx.parser = parser

match args.command:
case "sessions":
cmd.sessions()
case "login":
cmd.login()
case "group":
cmd.group()
case "profile":
cmd.profile()
case None:
if args.username:
user = ctx.session.connect_user(args.username)
console.print(cli.try_get_img(user.icon, (30, 30)))
console.print(user)
return
if args.studio_id:
studio = ctx.session.connect_studio(args.studio_id)
console.print(cli.try_get_img(studio.thumbnail, (34, 20)))
console.print(studio)
return
if args.project_id:
project = ctx.session.connect_project(args.project_id)
console.print(cli.try_get_img(project.thumbnail, (30, 23)))
console.print(project)
return
if args.session_name:
if sess := ctx.db_get_sess(args.session_name):
console.print(sess)
else:
raise ValueError(f"No session logged in called {args.session_name!r} "
f"- try using `scratch sessions` to see available sessions")
return

parser.print_help()


if __name__ == '__main__':
main()
1 change: 1 addition & 0 deletions scratchattach/cli/__about__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
VERSION = "0.0.0"
26 changes: 26 additions & 0 deletions scratchattach/cli/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""
Only for use within __main__.py
"""
import io

from rich.console import RenderableType
from typing_extensions import Optional
from scratchattach.cli.__about__ import VERSION
from scratchattach.cli.namespace import ArgSpace
from scratchattach.cli.context import ctx


# noinspection PyPackageRequirements
def try_get_img(image: bytes, size: tuple[int, int] | None = None) -> Optional[RenderableType]:
try:
from PIL import Image
from rich_pixels import Pixels

with Image.open(io.BytesIO(image)) as image:
if size is not None:
image = image.resize(size)

return Pixels.from_image(image)

except ImportError:
return ""
4 changes: 4 additions & 0 deletions scratchattach/cli/cmd/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from .login import login
from .group import group
from .profile import profile
from .sessions import sessions
127 changes: 127 additions & 0 deletions scratchattach/cli/cmd/group.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
from scratchattach.cli import db
from scratchattach.cli.context import console, ctx

from rich.markup import escape
from rich.table import Table

def _list():
table = Table(title="All groups")
table.add_column("Name")
table.add_column("Description")
table.add_column("Usernames")

db.cursor.execute("SELECT NAME, DESCRIPTION FROM GROUPS")
for name, description in db.cursor.fetchall():
db.cursor.execute("SELECT USERNAME FROM GROUP_USERS WHERE GROUP_NAME=?", (name,))
usernames = db.cursor.fetchall()

table.add_row(escape(name), escape(description),
'\n'.join(f"{i}. {u}" for i, (u,) in enumerate(usernames)))

console.print(table)

def add(group_name: str):
accounts = input("Add accounts (split by space): ").split()
for account in accounts:
ctx.db_add_to_group(group_name, account)

def remove(group_name: str):
accounts = input("Remove accounts (split by space): ").split()
for account in accounts:
ctx.db_remove_from_group(group_name, account)

def new():
console.rule(f"New group {escape(ctx.args.group_name)}")
if ctx.db_group_exists(ctx.args.group_name):
raise ValueError(f"Group {escape(ctx.args.group_name)} already exists")

db.conn.execute("BEGIN")
db.cursor.execute("INSERT INTO GROUPS (NAME, DESCRIPTION) "
"VALUES (?, ?)", (ctx.args.group_name, input("Description: ")))
db.conn.commit()
add(ctx.args.group_name)

_group(ctx.args.group_name)

def _group(group_name: str):
"""
Display information about a group
"""
db.cursor.execute(
"SELECT NAME, DESCRIPTION FROM GROUPS WHERE NAME = ?", (group_name,))
result = db.cursor.fetchone()
if result is None:
print("No group selected!!")
return

name, description = result

db.cursor.execute("SELECT USERNAME FROM GROUP_USERS WHERE GROUP_NAME = ?", (name,))
usernames = [name for (name,) in db.cursor.fetchall()]

table = Table(title=escape(group_name))
table.add_column(escape(name))
table.add_column('Usernames')

table.add_row(escape(description),
'\n'.join(f"{i}. {u}" for i, u in enumerate(usernames)))

console.print(table)

def switch():
console.rule(f"Switching to {escape(ctx.args.group_name)}")
if not ctx.db_group_exists(ctx.args.group_name):
raise ValueError(f"Group {escape(ctx.args.group_name)} does not exist")

ctx.current_group_name = ctx.args.group_name
_group(ctx.current_group_name)

def delete(group_name: str):
print(f"Deleting {group_name}")
if not ctx.db_group_exists(group_name):
raise ValueError(f"Group {group_name} does not exist")
if ctx.db_group_count == 1:
raise ValueError(f"Make another group first")

ctx.db_group_delete(group_name)

def copy(group_name: str, new_name: str):
print(f"Copying {group_name} as {new_name}")
if not ctx.db_group_exists(group_name):
raise ValueError(f"Group {group_name} does not exist")

ctx.db_group_copy(group_name, new_name)

def rename(group_name: str, new_name: str):
copy(group_name, new_name)
delete(group_name)

def group():
match ctx.args.group_command:
case "list":
_list()
case "new":
new()
case "switch":
switch()
case "add":
add(ctx.current_group_name)
case "remove":
remove(ctx.current_group_name)
case "delete":
if input("Are you sure? (y/N): ").lower() != "y":
return
delete(ctx.current_group_name)
new_current = ctx.db_first_group_name
print(f"Switching to {new_current}")
ctx.current_group_name = new_current
_group(new_current)

case "copy":
copy(ctx.current_group_name, ctx.args.group_name)
case "rename":
rename(ctx.current_group_name, ctx.args.group_name)
ctx.current_group_name = ctx.args.group_name
_group(ctx.args.group_name)
case None:
_group(ctx.current_group_name)
60 changes: 60 additions & 0 deletions scratchattach/cli/cmd/login.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
from scratchattach.cli.context import ctx, console
from scratchattach.cli import db
from rich.markup import escape

from getpass import getpass

import scratchattach as sa
import warnings

warnings.filterwarnings("ignore", category=sa.LoginDataWarning)


def login():
if ctx.args.sessid:
if isinstance(ctx.args.sessid, bool):
ctx.args.sessid = getpass("Session ID: ")

session = sa.login_by_id(ctx.args.sessid)
password = None
else:
username = input("Username: ")
password = getpass()

session = sa.login(username, password)

console.rule()
console.print(f"Logged in as [b]{session.username}[/]")

# register session
db.conn.execute("BEGIN")
db.cursor.execute(
"INSERT OR REPLACE INTO SESSIONS (ID, USERNAME, PASSWORD) "
"VALUES (?, ?, ?)", (session.id, session.username, password)
)
db.conn.commit()

# make new group
db.cursor.execute("SELECT NAME FROM GROUPS WHERE NAME = ?", (session.username,))
if not db.cursor.fetchone():
console.rule(f"Registering [b]{escape(session.username)}[/] as group")
db.conn.execute("BEGIN")
db.cursor.execute(
"INSERT INTO GROUPS (NAME, DESCRIPTION) "
"VALUES (?, ?)", (session.username, input(f"Description for {session.username}: "))
).execute(
"INSERT INTO GROUP_USERS (GROUP_NAME, USERNAME) "
"VALUES (?, ?)", (session.username, session.username)
)

db.conn.commit()

console.rule()
if input("Add to current session group? (Y/n)").lower() not in ("y", ''):
return

db.conn.execute("BEGIN")
db.cursor.execute("INSERT INTO GROUP_USERS (GROUP_NAME, USERNAME) "
"VALUES (?, ?)", (ctx.current_group_name, session.username))
db.conn.commit()
console.print(f"Added to [b]{escape(ctx.current_group_name)}[/]")
7 changes: 7 additions & 0 deletions scratchattach/cli/cmd/profile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from scratchattach.cli.context import ctx, console

@ctx.sessionable
def profile():
console.rule()
user = ctx.session.connect_linked_user()
console.print(user)
5 changes: 5 additions & 0 deletions scratchattach/cli/cmd/sessions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from scratchattach.cli.context import ctx, console

@ctx.sessionable
def sessions():
console.print(ctx.session)
Loading