today 업무 보조 툴 개발 배경: 자주 사용하는 각종 주소 치기, 필요한 커맨드 치기 귀찮음. 이를 효율적으로 관리하고 싶음 툴 이름: today 명세: today 입력 시 현재 쉘(tcsh)에서 실행됨 먼저 등록된 shortcut list(label, path, command_list)보여지고, 숫자 입력 시 해당 path로 이동, command 실행 숫자가 아니라 등록 기능을 실행 (A)dd 시 interactive하게 label, path(기본은 현재 장소가 쳐진 상태, 만약 바꾸고 싶으면 지우고 치게할 수 있을까? 만약 아무것도 없으면 나중에 cd 자체를 안하게 하고 싶음), command list(;로 구분됨)을 각각 입력 받음 삭제 기능도 필요 내가 생각하고 있는 구현법(꼭 따르지 않아도 되며 더 좋은 방법이 있으면 생각해내도 됨) today.csh: tcsh 파일, 내부에서 파이썬 스크립트를 실행 (argument로 커맨드 저장할 경로 전달 (e.g. ~/.config/today/command_YYMMDD_HHMMSS.csh)) 명세 2번은 파이썬에서 실행되고 전달 받은 경로에 실행할 커맨드들을 write (e.g. cd path; command1; command2;) 그 후 tcsh 나머지 코드에서 해당 경로를 source

좋아요—요구하신 “today” 업무 보조 툴을 tcsh용으로 설계·구현해서 바로 쓸 수 있는 형태로 만들어 드렸어요. 아래 순서대로 진행하면 끝납니다. (요청하신 대로 단계별로 생각·설계·구현을 풀어서 안내할게요.)

⬇️ today.csh / today.py 내려받기


1) 목표 정리 (step-by-step 사고)

  1. today현재 tcsh 세션 안에서 실행 → 목록 표시 → 번호 입력 시

    • (선택한 항목의) cd <path> 실행

    • 이어서 command_list를 차례로 실행

  2. 목록 외 입력:

    • A(Add): label / path / command_list(;로 구분) 인터랙티브 입력

      • Path 기본값은 현재 디렉터리

        • Enter → 현재 디렉터리 사용

        • -(하이픈) → cd 하지 않음

        • 그 외 직접 경로 입력

    • D(Delete): 숫자(들)로 삭제

    • Q 또는 빈 입력 → 종료

  3. 내부 동작 방식

    • today.csh(tcsh 스크립트) → python3 today.py 실행

    • 사용자가 번호를 골랐을 때만, 파이썬이 cd와 커맨드를 담은 임시 .csh 파일 을 작성

    • today.csh가 그 파일을 source 해서 현재 쉘 에서 실행되도록 함

    • 실행 후 임시 파일 삭제

  4. 데이터 저장 형식

    • ~/.config/today/shortcuts.json (JSON)
      [
        {"label":"예시","path":"/path/to/dir","command_list":["ls -al","git status"]}
      ]
      
    • 파일 없으면 자동 생성

2) 설치

  1. 위의 압축 폴더에서 두 파일을 복사:
  • today.py~/.local/bin/today.py

  • today.csh~/.local/bin/today.csh

  1. 실행 권한:
tcsh

chmod +x ~/.local/bin/today.py
chmod +x ~/.local/bin/today.csh
  1. tcsh에 alias 추가(현재 쉘에서 동작시키려면 source 필요):
  • ~/.tcshrc에 다음 한 줄을 넣고 새 세션을 열거나 source ~/.tcshrc:
tcsh

alias today 'source ~/.local/bin/today.csh'

참고: today.csh는 기본적으로 ~/.local/bin/today.py를 찾습니다. 위치를 바꿨다면 today.csh 안의 set script_path = ... 경로만 수정하면 돼요.


3) 사용법

  • today 입력 → 등록 목록 표시

    • 번호 입력 → 해당 항목 실행(필요 시 cd 후 커맨드 순차 실행)

    • A → 추가 모드

      • Label: 필수

      • Path: 프롬프트에 [현재경로] 표시

        • Enter → 표시된 현재경로 사용

        • -cd 생략(= path 없음)

        • 그 외 직접 입력

      • Commands: ;로 구분 (예: git pull; npm run start)

    • D → 삭제(여러 개면 공백/콤마 구분)

    • Q 또는 빈 입력 → 종료


4) 코드 개요 (요점만)

today.csh (tcsh 래퍼)

  • 설정 디렉터리 ~/.config/today 생성

  • 타임스탬프 기반 임시 실행 파일 경로 생성: command_YYMMDD_HHMMSS.csh

  • python3 today.py --db ~/.config/today/shortcuts.json --out <임시파일> 호출

  • 파이썬이 임시파일을 만들었으면 source 하고 삭제
    (→ cd/커맨드가 현재 쉘 에서 실행됨)

today.py (인터랙티브 UI + 실행 스크립트 생성)

  • 목록 출력 → 입력 처리(번호/A/D/Q)

  • Add:

    • Path 입력 가이드: Enter=현재경로, - = cd하지 않음, 그 외 수동입력

    • Commands: ;로 split, 공백 트림

  • Delete:

    • 여러 번호 처리, 역순 삭제
  • Run(번호):

    • 선택 항목의 cd와 커맨드를 담은 tcsh 스크립트 생성

      • if ( -d "<path>" ) cd "<path>"

      • 명령어들은 줄바꿈으로 한 줄씩 출력

  • DB 저장은 원자적 저장(임시파일 → os.replace)


5) 작은 예시

  1. todayA

    • Label: work-log

    • Path: Enter (현재 디렉터리)

    • Commands: git pull; code .

  2. 다시 today1 입력

    • 현재 디렉터리로 cd (또는 생략됨) → git pullcode .

6) 확장 아이디어 (선택)

  • Edit (E) 지원: 기존 항목 수정(오늘 스펙엔 없어서 기본 미구현)

  • 검색 (/) 지원 : label 키워드로 필터 표시

  • label 바로 실행 : today some-label 같은 논옵션 실행(현재는 인터랙티브만)

  • 백업/복원 : ~/.config/today/shortcuts.json 버전 관리


필요하시면 Edit 기능이나 비인터랙티브 실행(today 3/today label)도 바로 추가해 드릴게요.

두 파일 그냥 본문에 써줘.

today.csh

tcsh

#!/bin/tcsh
# today.csh - tcsh wrapper that calls today.py and sources the generated command file
# Usage: type `today` in your tcsh (after setting alias) to run interactively.

# Ensure config directory exists
if ( ! -d "$HOME/.config/today" ) then
    mkdir -p "$HOME/.config/today"
endif

set config_dir = "$HOME/.config/today"
set timestamp = `date +"%y%m%d_%H%M%S"`
set out_file = "$config_dir/command_$timestamp.csh"

# Prefer python3
set python = `which python3`
if ( "$python" == "" ) then
    echo "python3 not found in PATH"
    exit 1
endif

# Default install path for today.py (adjust if you put it elsewhere)
set script_path = "$HOME/.local/bin/today.py"
if ( ! -e "$script_path" ) then
    echo "today.py not found at $script_path"
    echo "Please place today.py there or edit today.csh to point to the correct path."
    exit 1
endif

# Run the Python helper (it writes $out_file if you selected a shortcut to run)
"$python" "$script_path" --db "$config_dir/shortcuts.json" --out "$out_file"
set code = $status
if ( $code != 0 ) then
    exit $code
endif

# If a command file was produced, source it in the CURRENT shell, then remove it
if ( -e "$out_file" ) then
    source "$out_file"
    /bin/rm -f "$out_file"
endif

today.py

#!/usr/bin/env python3
# today.py - interactive shortcut runner/editor for tcsh via today.csh
import argparse, json, os, sys, tempfile, shutil
from pathlib import Path

def load_db(db_path: Path):
    if not db_path.exists():
        return []
    try:
        with db_path.open("r", encoding="utf-8") as f:
            data = json.load(f)
        # Normalize
        norm = []
        for item in data:
            norm.append({
                "label": item.get("label","").strip(),
                "path": item.get("path"),
                "command_list": item.get("command_list", []) or []
            })
        return norm
    except Exception as e:
        print(f"[today] Failed to read DB: {e}", file=sys.stderr)
        return []

def save_db(db_path: Path, items):
    db_path.parent.mkdir(parents=True, exist_ok=True)
    tmp = db_path.with_suffix(".tmp")
    with tmp.open("w", encoding="utf-8") as f:
        json.dump(items, f, ensure_ascii=False, indent=2)
    os.replace(tmp, db_path)

def print_list(items):
    if not items:
        print("등록된 shortcut이 없습니다. (A로 추가)")
        return
    print("== Shortcuts ==")
    for i, it in enumerate(items, start=1):
        label = it.get("label","(no label)")
        path  = it.get("path") or "(no cd)"
        cmds  = "; ".join(it.get("command_list", [])) or "(no commands)"
        print(f"{i:>3}) {label}\n     path: {path}\n     cmd : {cmds}")

def prompt(msg, default=None):
    if default is None:
        return input(msg).strip()
    else:
        s = input(f"{msg} [{default}] ").strip()
        if s == "":
            return default
        return s

def do_add(items):
    print("\n--- Add Shortcut ---")
    while True:
        label = input("Label: ").strip()
        if label:
            break
        print("Label은 비어 있을 수 없습니다.")
    cwd = os.environ.get("PWD", os.getcwd())
    print("Path 입력 방법: Enter=현재 경로 사용, '-'=cd 하지 않음, 그 외 직접 경로 입력")
    raw_path = input(f"Path [{cwd}]: ").strip()
    if raw_path == "":
        path = cwd
    elif raw_path == "-":
        path = None
    else:
        path = raw_path

    raw_cmds = input("Command list (';'로 구분, 비우면 없음): ").strip()
    command_list = [c.strip() for c in raw_cmds.split(";") if c.strip()] if raw_cmds else []

    items.append({"label": label, "path": path, "command_list": command_list})
    print(f"추가됨: {label}")
    return items

def do_delete(items):
    if not items:
        print("삭제할 항목이 없습니다.")
        return items
    print_list(items)
    s = input("삭제할 번호(여러 개는 공백/콤마로 구분): ").strip()
    if not s:
        return items
    picks = []
    for tok in s.replace(",", " ").split():
        if tok.isdigit():
            idx = int(tok)
            if 1 <= idx <= len(items):
                picks.append(idx-1)
    if not picks:
        print("유효한 번호가 없습니다.")
        return items
    for i in sorted(set(picks), reverse=True):
        removed = items.pop(i)
        print(f"삭제됨: {removed.get('label','(no label)')}")
    return items

def write_tcsh(out_path: Path, path, command_list):
    out_path.parent.mkdir(parents=True, exist_ok=True)
    with out_path.open("w", encoding="utf-8") as f:
        f.write("#!/bin/tcsh\n")
        f.write("# Auto-generated by today.py\n")
        if path:
            # Quote path safely
            qpath = path.replace('"', r'\"')
            f.write(f'if ( -d "{qpath}" ) cd "{qpath}"\n')
        for cmd in command_list:
            f.write(cmd + "\n")
    os.chmod(out_path, 0o700)

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--db", required=True, help="Path to shortcuts.json")
    ap.add_argument("--out", required=True, help="Path to write the tcsh command file")
    args = ap.parse_args()
    db_path = Path(os.path.expanduser(args.db))
    out_path = Path(os.path.expanduser(args.out))
    items = load_db(db_path)

    # UI
    print_list(items)
    print("\n선택: 번호=실행, A=추가, D=삭제, Q=종료")
    choice = input("> ").strip()
    if choice.lower() == "q" or choice == "":
        # No action; do not write out file
        return 0
    elif choice.lower() == "a":
        items = do_add(items)
        save_db(db_path, items)
        return 0
    elif choice.lower() == "d":
        items = do_delete(items)
        save_db(db_path, items)
        return 0
    elif choice.isdigit():
        idx = int(choice) - 1
        if 0 <= idx < len(items):
            picked = items[idx]
            write_tcsh(out_path, picked.get("path"), picked.get("command_list", []))
            print(f"실행 준비: {picked.get('label')}")
            return 0
        else:
            print("잘못된 번호입니다.")
            return 2
    else:
        print("알 수 없는 입력입니다.")
        return 2

if __name__ == "__main__":
    sys.exit(main())

출력 화면에 색깔이나 표 형식같은 포매팅을 넣어서 가시성을 높이는거 가능해?

가능해요! 컬러와 표 형태를 지원하도록 today.py를 업데이트한 버전을 아래에 넣었습니다.

  • 기본은 자동 감지(TTY + NO_COLOR 미설정이면 컬러 가능).

  • 선택적으로rich가 설치되어 있으면 더 예쁜 표로 보여주고, 없으면 ANSI 컬러 + ASCII 표 로 깔끔히 출력합니다.

  • 컬러를 끄고 싶으면 --no-color 옵션이나 환경변수 NO_COLOR=1을 사용하세요.

  • today.csh는 그대로 쓰시면 됩니다.


today.py (포매팅 강화 버전)

#!/usr/bin/env python3
# today.py - interactive shortcut runner/editor for tcsh via today.csh
import argparse, json, os, sys, shutil
from pathlib import Path

# -------- Color / Table detection --------
HAVE_RICH = False
console = None
try:
    # Optional pretty output if 'rich' is available
    from rich.console import Console
    from rich.table import Table
    from rich import box
    HAVE_RICH = True
    console = Console()
except Exception:
    HAVE_RICH = False
    console = None

def term_width(default=100):
    try:
        return shutil.get_terminal_size((default, 20)).columns
    except Exception:
        return default

def ansi_enabled(no_color_flag: bool) -> bool:
    # Enable ANSI if stdout is a TTY, NO_COLOR not set, and user didn't pass --no-color
    if no_color_flag: return False
    if os.environ.get("NO_COLOR"): return False
    return sys.stdout.isatty()

# Basic ANSI styles
class S:
    def __init__(self, on: bool):
        self.on = on
        self.reset = "\033[0m" if on else ""
        self.header = "\033[1;36m" if on else ""
        self.index  = "\033[1;33m" if on else ""
        self.ok     = "\033[1;32m" if on else ""
        self.warn   = "\033[1;31m" if on else ""
        self.dim    = "\033[2m"    if on else ""
        self.bold   = "\033[1m"    if on else ""

# -------- Data IO --------
def load_db(db_path: Path):
    if not db_path.exists():
        return []
    try:
        with db_path.open("r", encoding="utf-8") as f:
            data = json.load(f)
        norm = []
        for item in data:
            norm.append({
                "label": item.get("label","").strip(),
                "path": item.get("path"),
                "command_list": item.get("command_list", []) or []
            })
        return norm
    except Exception as e:
        print(f"[today] Failed to read DB: {e}", file=sys.stderr)
        return []

def save_db(db_path: Path, items):
    db_path.parent.mkdir(parents=True, exist_ok=True)
    tmp = db_path.with_suffix(".tmp")
    with tmp.open("w", encoding="utf-8") as f:
        json.dump(items, f, ensure_ascii=False, indent=2)
    os.replace(tmp, db_path)

# -------- Rendering --------
def ellipsize(s: str, maxlen: int) -> str:
    if len(s) <= maxlen: return s
    if maxlen <= 1: return s[:maxlen]
    return s[:maxlen-1] + ""

def render_list(items, styles: S, use_rich: bool):
    if not items:
        msg = "등록된 shortcut이 없습니다. (A로 추가)"
        if use_rich and HAVE_RICH:
            console.print(f"[bold yellow]{msg}[/bold yellow]")
        else:
            print(f"{styles.index}{msg}{styles.reset}")
        return

    # Decide column widths
    width = term_width()
    # Base min widths
    w_idx = max(2, len(str(len(items))))
    # we'll allocate remaining width among label/path/cmds heuristically
    remaining = max(20, width - (w_idx + 6))  # borders + spacing allowance
    w_label = max(10, int(remaining * 0.25))
    w_path  = max(10, int(remaining * 0.35))
    w_cmds  = max(10, remaining - w_label - w_path)

    if use_rich and HAVE_RICH:
        table = Table(box=box.SIMPLE, show_lines=False, expand=False)
        table.add_column("#", justify="right", style="bold yellow", no_wrap=True)
        table.add_column("Label", overflow="fold", max_width=w_label)
        table.add_column("Path", style="dim", overflow="fold", max_width=w_path)
        table.add_column("Commands", overflow="fold", max_width=w_cmds)

        for i, it in enumerate(items, start=1):
            label = it.get("label","(no label)") or "(no label)"
            path  = it.get("path") or "(no cd)"
            cmds  = "; ".join(it.get("command_list", [])) or "(no commands)"
            table.add_row(str(i), label, path, cmds)
        console.print(table)
    else:
        # Plain ASCII table with optional ANSI header
        headers = ["#", "Label", "Path", "Commands"]
        rows = []
        for i, it in enumerate(items, start=1):
            label = ellipsize(it.get("label","(no label)") or "(no label)", w_label)
            path  = ellipsize(it.get("path") or "(no cd)", w_path)
            cmds  = ellipsize("; ".join(it.get("command_list", [])) or "(no commands)", w_cmds)
            rows.append([str(i), label, path, cmds])

        widths = [
            max(len(headers[0]), max(len(r[0]) for r in rows)),
            max(len(headers[1]), max(len(r[1]) for r in rows)),
            max(len(headers[2]), max(len(r[2]) for r in rows)),
            max(len(headers[3]), max(len(r[3]) for r in rows)),
        ]

        def sep():
            print("+" + "+".join("-"*(w+2) for w in widths) + "+")

        def row_print(vals, is_header=False):
            out = []
            for i, v in enumerate(vals):
                txt = f" {v.ljust(widths[i])} "
                out.append(txt)
            line = "|" + "|".join(out) + "|"
            if is_header and styles.on:
                # Header accent
                print(f"{styles.header}{line}{styles.reset}")
            else:
                print(line)

        sep()
        row_print(headers, is_header=True)
        sep()
        for r in rows:
            row_print(r)
        sep()

def info_line(msg: str, styles: S, kind="ok"):
    if HAVE_RICH and console:
        style = {"ok": "bold green", "warn": "bold red", "note": "bold cyan"}.get(kind, "")
        console.print(f"[{style}]{msg}[/{style}]" if style else msg)
    else:
        color = styles.ok if kind=="ok" else styles.warn if kind=="warn" else styles.header
        print(f"{color}{msg}{styles.reset}")

# -------- Interactive ops --------
def do_add(items, styles: S, use_rich: bool):
    if HAVE_RICH and console and use_rich:
        console.print("[bold cyan]\n--- Add Shortcut ---[/bold cyan]")
    else:
        print("\n--- Add Shortcut ---")
    while True:
        label = input("Label: ").strip()
        if label:
            break
        info_line("Label은 비어 있을 수 없습니다.", styles, "warn")

    cwd = os.environ.get("PWD", os.getcwd())
    print("Path 입력 방법: Enter=현재 경로 사용, '-'=cd 하지 않음, 그 외 직접 경로 입력")
    raw_path = input(f"Path [{cwd}]: ").strip()
    if raw_path == "":
        path = cwd
    elif raw_path == "-":
        path = None
    else:
        path = raw_path

    raw_cmds = input("Command list (';'로 구분, 비우면 없음): ").strip()
    command_list = [c.strip() for c in raw_cmds.split(";") if c.strip()] if raw_cmds else []

    items.append({"label": label, "path": path, "command_list": command_list})
    info_line(f"추가됨: {label}", styles, "ok")
    return items

def do_delete(items, styles: S, use_rich: bool):
    if not items:
        info_line("삭제할 항목이 없습니다.", styles, "warn")
        return items
    render_list(items, styles, use_rich)
    s = input("삭제할 번호(여러 개는 공백/콤마로 구분): ").strip()
    if not s:
        return items
    picks = []
    for tok in s.replace(",", " ").split():
        if tok.isdigit():
            idx = int(tok)
            if 1 <= idx <= len(items):
                picks.append(idx-1)
    if not picks:
        info_line("유효한 번호가 없습니다.", styles, "warn")
        return items
    for i in sorted(set(picks), reverse=True):
        removed = items.pop(i)
        info_line(f"삭제됨: {removed.get('label','(no label)')}", styles, "ok")
    return items

def write_tcsh(out_path: Path, path, command_list):
    out_path.parent.mkdir(parents=True, exist_ok=True)
    with out_path.open("w", encoding="utf-8") as f:
        f.write("#!/bin/tcsh\n")
        f.write("# Auto-generated by today.py\n")
        if path:
            # Quote path safely
            qpath = path.replace('"', r'\"')
            f.write(f'if ( -d "{qpath}" ) cd "{qpath}"\n')
        for cmd in command_list:
            f.write(cmd + "\n")
    os.chmod(out_path, 0o700)

# -------- Main --------
def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--db", required=True, help="Path to shortcuts.json")
    ap.add_argument("--out", required=True, help="Path to write the tcsh command file")
    ap.add_argument("--no-color", action="store_true", help="Disable colored/pretty output")
    args = ap.parse_args()

    db_path = Path(os.path.expanduser(args.db))
    out_path = Path(os.path.expanduser(args.out))

    use_rich = HAVE_RICH and ansi_enabled(args.no_color)
    styles = S(ansi_enabled(args.no_color))

    items = load_db(db_path)

    # UI
    if HAVE_RICH and use_rich:
        console.print("[bold cyan]== Shortcuts ==[/bold cyan]")
    else:
        print(f"{styles.header}== Shortcuts =={styles.reset}")
    render_list(items, styles, use_rich)

    print("\n선택: 번호=실행, A=추가, D=삭제, Q=종료")
    choice = input("> ").strip()
    if choice.lower() == "q" or choice == "":
        return 0
    elif choice.lower() == "a":
        items = do_add(items, styles, use_rich)
        save_db(db_path, items)
        return 0
    elif choice.lower() == "d":
        items = do_delete(items, styles, use_rich)
        save_db(db_path, items)
        return 0
    elif choice.isdigit():
        idx = int(choice) - 1
        if 0 <= idx < len(items):
            picked = items[idx]
            write_tcsh(out_path, picked.get("path"), picked.get("command_list", []))
            info_line(f"실행 준비: {picked.get('label')}", styles, "ok")
            return 0
        else:
            info_line("잘못된 번호입니다.", styles, "warn")
            return 2
    else:
        info_line("알 수 없는 입력입니다.", styles, "warn")
        return 2

if __name__ == "__main__":
    sys.exit(main())

사용 팁 (짧게, step-by-step)

  1. 그냥 써도 컬러/표가 적용됩니다.

  2. 더 예쁜 표를 원하면 pip install rich 해두세요(없어도 동작).

  3. 컬러 끄기:

    • 일시: today 실행 시 자동(현재는 파이썬 스크립트가 실행될 때 --no-color 전달 옵션이 없음) → 직접 테스트하려면 today.csh에서 --no-color를 today.py 호출 뒤에 붙여도 됩니다.

    • 영구: NO_COLOR=1 환경변수 설정.

원하시면 today.csh에서 --no-color를 전달하도록 바꾸거나, 라벨/경로 너비 고정값을 조정하는 옵션도 바로 넣어드릴게요.

1. json 말고 일반인이 직관적으로 config 편집하기 좋은 포맷은 없을까 2. remove를 빼고 edit 선택 시 에디터로 config가 열리고 거기서 편집/삭제하는게 더 편할 것 같아.

좋은 아이디어예요. 정리하면:

  1. JSON 대신 편집 친화 포맷TOML 추천

    • 배열과 문자열을 직관적으로 쓰기 좋고, 주석도 달 수 있어요.

    • 예:

      # ~/.config/today/shortcuts.toml
      # path를 빼면(cd 생략) 현재 디렉터리를 유지합니다.
             
      [[shortcut]]
      label = "repo"
      path = "/work/repo"
      commands = ["git status", "git pull"]
             
      [[shortcut]]
      label = "here-no-cd"
      commands = ["ls -al"]
      
  2. Remove 대신 Edit

    • 메뉴에서 D를 없애고 E(편집) 추가 → $VISUAL/$EDITOR(없으면 vi)로 TOML을 열어 직접 수정/삭제.

    • 저장 후 닫으면 파서로 검증하고, 실패 시 백업에서 복원.

아래는 그 기능까지 반영한 최종 코드입니다. (이전 컬러/표 출력도 그대로 지원; rich 있으면 더 예쁘게 표 출력)


today.csh

tcsh

#!/bin/tcsh
# today.csh - tcsh wrapper that calls today.py and sources the generated command file
# Usage: type `today` in your tcsh (after setting alias) to run interactively.

# Ensure config directory exists
if ( ! -d "$HOME/.config/today" ) then
    mkdir -p "$HOME/.config/today"
endif

set config_dir = "$HOME/.config/today"
set config_file = "$config_dir/shortcuts.toml"
set timestamp = `date +"%y%m%d_%H%M%S"`
set out_file = "$config_dir/command_$timestamp.csh"

# Prefer python3
set python = `which python3`
if ( "$python" == "" ) then
    echo "python3 not found in PATH"
    exit 1
endif

# Default install path for today.py (adjust if you put it elsewhere)
set script_path = "$HOME/.local/bin/today.py"
if ( ! -e "$script_path" ) then
    echo "today.py not found at $script_path"
    echo "Please place today.py there or edit today.csh to point to the correct path."
    exit 1
endif

# Run the Python helper (it writes $out_file if you selected a shortcut to run)
"$python" "$script_path" --db "$config_file" --out "$out_file"
set code = $status
if ( $code != 0 ) then
    exit $code
endif

# If a command file was produced, source it in the CURRENT shell, then remove it
if ( -e "$out_file" ) then
    source "$out_file"
    /bin/rm -f "$out_file"
endif

today.py

#!/usr/bin/env python3
# today.py - interactive shortcut runner/editor for tcsh via today.csh
# - Config: TOML (~/.config/today/shortcuts.toml)
# - E(편집): $VISUAL/$EDITOR(없으면 vi)로 TOML을 열어 수정/삭제
# - Optional pretty list with 'rich' if available
import argparse, json, os, sys, shutil, subprocess, shlex
from pathlib import Path

# ---------- Optional pretty output ----------
HAVE_RICH = False
console = None
try:
    from rich.console import Console
    from rich.table import Table
    from rich import box
    HAVE_RICH = True
    console = Console()
except Exception:
    HAVE_RICH = False
    console = None

def term_width(default=100):
    try:
        return shutil.get_terminal_size((default, 20)).columns
    except Exception:
        return default

def ansi_enabled(no_color_flag: bool) -> bool:
    if no_color_flag: return False
    if os.environ.get("NO_COLOR"): return False
    return sys.stdout.isatty()

class S:
    def __init__(self, on: bool):
        self.on = on
        self.reset = "\033[0m" if on else ""
        self.header = "\033[1;36m" if on else ""
        self.index  = "\033[1;33m" if on else ""
        self.ok     = "\033[1;32m" if on else ""
        self.warn   = "\033[1;31m" if on else ""
        self.dim    = "\033[2m"    if on else ""
        self.bold   = "\033[1m"    if on else ""

# ---------- TOML parsing/writing ----------
# Parse: prefer tomllib (py3.11+), fallback tomli
_TOML = None
try:
    import tomllib as _TOML  # Python 3.11+
except Exception:
    try:
        import tomli as _TOML  # pip install tomli
    except Exception:
        _TOML = None

def parse_toml(path: Path):
    if not path.exists():
        return {"shortcut": []}
    if _TOML is None:
        raise RuntimeError("TOML parser not found. Use Python 3.11+ or `pip install tomli`.")
    with path.open("rb") as f:
        data = _TOML.load(f)
    if not isinstance(data, dict):
        return {"shortcut": []}
    data.setdefault("shortcut", [])
    return data

def _escape_toml_string(s: str) -> str:
    return (
        s.replace("\\", "\\\\")
         .replace("\"", "\\\"")
         .replace("\t", "\\t")
         .replace("\n", "\\n")
    )

def dump_toml(items, path: Path):
    """
    Write as:
    [[shortcut]]
    label = "..."
    path = "/path"   # optional; omit for 'no cd'
    commands = ["a", "b"]
    """
    lines = []
    lines.append("# today shortcuts (TOML)")
    lines.append("# path 키를 생략하면(cd 생략) 현재 디렉터리를 유지합니다.")
    lines.append("")
    for it in items:
        label = it.get("label","").strip()
        pathv = it.get("path", None)
        cmds  = it.get("command_list", []) or []
        lines.append("[[shortcut]]")
        lines.append(f'label = "{_escape_toml_string(label)}"')
        if pathv:
            lines.append(f'path = "{_escape_toml_string(str(pathv))}"')
        if cmds:
            q = ", ".join(f"\"{_escape_toml_string(c)}\"" for c in cmds)
            lines.append(f"commands = [{q}]")
        else:
            lines.append("commands = []")
        lines.append("")
    content = "\n".join(lines)
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_suffix(".tmp")
    with tmp.open("w", encoding="utf-8") as f:
        f.write(content)
    os.replace(tmp, path)

# ---------- Data IO with migration ----------
def load_items_from_toml(db_path: Path):
    data = parse_toml(db_path)
    items = []
    for ent in data.get("shortcut", []):
        if not isinstance(ent, dict): 
            continue
        label = (ent.get("label") or "").strip()
        pathv = ent.get("path", None)
        cmds  = ent.get("commands", [])
        if not isinstance(cmds, list):
            cmds = []
        cmds = [str(c).strip() for c in cmds if str(c).strip()]
        items.append({"label": label, "path": pathv, "command_list": cmds})
    return items

def load_items(db_path: Path):
    # If TOML exists -> load TOML
    if db_path.suffix.lower() == ".toml" and db_path.exists():
        return load_items_from_toml(db_path)
    # If TOML missing but legacy JSON exists -> migrate
    if db_path.suffix.lower() == ".toml":
        legacy_json = db_path.with_suffix(".json")
        if legacy_json.exists():
            try:
                with legacy_json.open("r", encoding="utf-8") as f:
                    data = json.load(f)
                items = []
                for item in data:
                    items.append({
                        "label": item.get("label","").strip(),
                        "path": item.get("path"),
                        "command_list": item.get("command_list", []) or []
                    })
                # Write TOML immediately
                dump_toml(items, db_path)
                return items
            except Exception:
                pass
    # Fallback empty
    return []

def save_items_to_toml(db_path: Path, items):
    dump_toml(items, db_path)

# ---------- Rendering ----------
def ellipsize(s: str, maxlen: int) -> str:
    if len(s) <= maxlen: return s
    if maxlen <= 1: return s[:maxlen]
    return s[:maxlen-1] + ""

def render_list(items, styles: S, use_rich: bool):
    if not items:
        msg = "등록된 shortcut이 없습니다. (A로 추가, E로 편집)"
        if use_rich and HAVE_RICH:
            console.print(f"[bold yellow]{msg}[/bold yellow]")
        else:
            print(f"{styles.index}{msg}{styles.reset}")
        return

    width = term_width()
    w_idx = max(2, len(str(len(items))))
    remaining = max(20, width - (w_idx + 6))
    w_label = max(10, int(remaining * 0.25))
    w_path  = max(10, int(remaining * 0.35))
    w_cmds  = max(10, remaining - w_label - w_path)

    if use_rich and HAVE_RICH:
        table = Table(box=box.SIMPLE, show_lines=False, expand=False)
        table.add_column("#", justify="right", style="bold yellow", no_wrap=True)
        table.add_column("Label", overflow="fold", max_width=w_label)
        table.add_column("Path", style="dim", overflow="fold", max_width=w_path)
        table.add_column("Commands", overflow="fold", max_width=w_cmds)
        for i, it in enumerate(items, start=1):
            label = it.get("label","(no label)") or "(no label)"
            path  = it.get("path") or "(no cd)"
            cmds  = "; ".join(it.get("command_list", [])) or "(no commands)"
            table.add_row(str(i), label, path, cmds)
        console.print(table)
    else:
        headers = ["#", "Label", "Path", "Commands"]
        rows = []
        for i, it in enumerate(items, start=1):
            label = ellipsize(it.get("label","(no label)") or "(no label)", w_label)
            path  = ellipsize(it.get("path") or "(no cd)", w_path)
            cmds  = ellipsize("; ".join(it.get("command_list", [])) or "(no commands)", w_cmds)
            rows.append([str(i), label, path, cmds])

        widths = [
            max(len(headers[0]), max((len(r[0]) for r in rows), default=1)),
            max(len(headers[1]), max((len(r[1]) for r in rows), default=1)),
            max(len(headers[2]), max((len(r[2]) for r in rows), default=1)),
            max(len(headers[3]), max((len(r[3]) for r in rows), default=1)),
        ]

        def sep():
            print("+" + "+".join("-"*(w+2) for w in widths) + "+")

        def row_print(vals, is_header=False):
            out = []
            for i, v in enumerate(vals):
                txt = f" {v.ljust(widths[i])} "
                out.append(txt)
            line = "|" + "|".join(out) + "|"
            if is_header and styles.on:
                print(f"{styles.header}{line}{styles.reset}")
            else:
                print(line)

        sep(); row_print(headers, is_header=True); sep()
        for r in rows:
            row_print(r)
        sep()

def info_line(msg: str, styles: S, kind="ok"):
    if HAVE_RICH and console:
        style = {"ok": "bold green", "warn": "bold red", "note": "bold cyan"}.get(kind, "")
        console.print(f"[{style}]{msg}[/{style}]" if style else msg)
    else:
        color = styles.ok if kind=="ok" else styles.warn if kind=="warn" else styles.header
        print(f"{color}{msg}{styles.reset}")

# ---------- Editor ----------
def choose_editor():
    ed = os.environ.get("VISUAL") or os.environ.get("EDITOR") or ""
    if ed.strip():
        return ed
    # fallbacks
    for cand in ["vi", "nano"]:
        from shutil import which
        if which(cand):
            return cand
    return "vi"

def open_in_editor(db_path: Path, items, styles: S):
    # Ensure file exists with current content (and comments)
    if not db_path.exists():
        dump_toml(items, db_path)
    bak = db_path.with_suffix(db_path.suffix + ".bak")
    try:
        if db_path.exists():
            shutil.copy2(db_path, bak)
    except Exception:
        pass

    editor = choose_editor()
    try:
        subprocess.run(shlex.split(f"{editor} {shlex.quote(str(db_path))}"), check=False)
    except Exception as e:
        info_line(f"에디터 실행 실패: {e}", styles, "warn")
        return

    # Validate edited TOML
    try:
        # Just parse; if ok we keep it. If parser missing -> warn but keep.
        if _TOML is None:
            info_line("TOML parser가 없어 변경 사항을 검증하지 못했습니다. (Python 3.11+ 또는 `pip install tomli` 권장)", styles, "warn")
            return
        _ = load_items_from_toml(db_path)  # raise if invalid format/types handled in parse
        info_line("저장 완료.", styles, "ok")
    except Exception as e:
        info_line(f"파싱 실패로 변경을 되돌립니다: {e}", styles, "warn")
        try:
            if bak.exists():
                shutil.copy2(bak, db_path)
        except Exception:
            info_line("백업 복원 실패. 파일을 수동으로 확인하세요.", styles, "warn")

# ---------- Interactive ops ----------
def do_add(items, styles: S, use_rich: bool):
    if HAVE_RICH and console and use_rich:
        console.print("[bold cyan]\n--- Add Shortcut ---[/bold cyan]")
    else:
        print("\n--- Add Shortcut ---")
    while True:
        label = input("Label: ").strip()
        if label:
            break
        info_line("Label은 비어 있을 수 없습니다.", styles, "warn")

    cwd = os.environ.get("PWD", os.getcwd())
    print("Path 입력: Enter=현재 경로, '-'=cd 하지 않음(키 생략), 그 외 직접 입력")
    raw_path = input(f"Path [{cwd}]: ").strip()
    if raw_path == "":
        pathv = cwd
    elif raw_path == "-":
        pathv = None
    else:
        pathv = raw_path

    raw_cmds = input("Command list (';'로 구분, 비우면 없음): ").strip()
    command_list = [c.strip() for c in raw_cmds.split(";") if c.strip()] if raw_cmds else []

    items.append({"label": label, "path": pathv, "command_list": command_list})
    info_line(f"추가됨: {label}", styles, "ok")
    return items

def write_tcsh(out_path: Path, pathv, command_list):
    out_path.parent.mkdir(parents=True, exist_ok=True)
    with out_path.open("w", encoding="utf-8") as f:
        f.write("#!/bin/tcsh\n")
        f.write("# Auto-generated by today.py\n")
        if pathv:
            qpath = str(pathv).replace('"', r'\"')
            f.write(f'if ( -d "{qpath}" ) cd "{qpath}"\n')
        for cmd in command_list:
            f.write(cmd + "\n")
    os.chmod(out_path, 0o700)

# ---------- Main ----------
def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--db", required=True, help="Path to shortcuts.toml (or legacy .json)")
    ap.add_argument("--out", required=True, help="Path to write the tcsh command file")
    ap.add_argument("--no-color", action="store_true", help="Disable colored/pretty output")
    args = ap.parse_args()

    db_path = Path(os.path.expanduser(args.db))
    out_path = Path(os.path.expanduser(args.out))

    use_rich = HAVE_RICH and ansi_enabled(args.no_color)
    styles = S(ansi_enabled(args.no_color))

    items = load_items(db_path)

    # UI
    if HAVE_RICH and use_rich:
        console.print("[bold cyan]== Shortcuts ==[/bold cyan]")
    else:
        print(f"{styles.header}== Shortcuts =={styles.reset}")
    render_list(items, styles, use_rich)

    print("\n선택: 번호=실행, A=추가, E=편집(에디터), Q=종료")
    choice = input("> ").strip()
    if choice.lower() == "q" or choice == "":
        return 0
    elif choice.lower() == "a":
        items = do_add(items, styles, use_rich)
        save_items_to_toml(db_path, items)
        return 0
    elif choice.lower() == "e":
        open_in_editor(db_path, items, styles)
        return 0
    elif choice.isdigit():
        idx = int(choice) - 1
        if 0 <= idx < len(items):
            picked = items[idx]
            write_tcsh(out_path, picked.get("path"), picked.get("command_list", []))
            info_line(f"실행 준비: {picked.get('label')}", styles, "ok")
            return 0
        else:
            info_line("잘못된 번호입니다.", styles, "warn")
            return 2
    else:
        info_line("알 수 없는 입력입니다.", styles, "warn")
        return 2

if __name__ == "__main__":
    sys.exit(main())

요약 (step-by-step)

  • 포맷: TOML 로 변경 → 사람이 읽고 수정하기 쉬움(주석 가능, 배열 직관적).

  • 메뉴: D 제거, E로 에디터 열어 편집/삭제.

  • 안정성: 저장 전/후 백업 & 파싱 검증, 실패 시 자동 복원.

  • 호환: 기존 shortcuts.json 있으면 처음 읽을 때 자동 TOML로 마이그레이션.

원하시면 라벨 바로 실행(today label)이나, 검색 필터 , 멀티프로필(~/.config/today/*.toml)도 붙여 드릴게요.

Categories:

Updated: