Today 업무 보조 툴
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 사고)
-
today를 현재 tcsh 세션 안에서 실행 → 목록 표시 → 번호 입력 시-
(선택한 항목의)
cd <path>실행 -
이어서
command_list를 차례로 실행
-
-
목록 외 입력:
-
A(Add): label / path / command_list(;로 구분) 인터랙티브 입력-
Path 기본값은 현재 디렉터리
-
Enter→ 현재 디렉터리 사용 -
-(하이픈) → cd 하지 않음 -
그 외 직접 경로 입력
-
-
-
D(Delete): 숫자(들)로 삭제 -
Q또는 빈 입력 → 종료
-
-
내부 동작 방식
-
today.csh(tcsh 스크립트) →python3 today.py실행 -
사용자가 번호를 골랐을 때만, 파이썬이
cd와 커맨드를 담은 임시 .csh 파일 을 작성 -
today.csh가 그 파일을 source 해서 현재 쉘 에서 실행되도록 함 -
실행 후 임시 파일 삭제
-
-
데이터 저장 형식
~/.config/today/shortcuts.json(JSON)[ {"label":"예시","path":"/path/to/dir","command_list":["ls -al","git status"]} ]- 파일 없으면 자동 생성
2) 설치
- 위의 압축 폴더에서 두 파일을 복사:
-
today.py→~/.local/bin/today.py -
today.csh→~/.local/bin/today.csh
- 실행 권한:
tcsh
chmod +x ~/.local/bin/today.py
chmod +x ~/.local/bin/today.csh
- 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) 작은 예시
-
today→A-
Label:
work-log -
Path:
Enter(현재 디렉터리) -
Commands:
git pull; code .
-
-
다시
today→1입력- 현재 디렉터리로
cd(또는 생략됨) →git pull→code .
- 현재 디렉터리로
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)
-
그냥 써도 컬러/표가 적용됩니다.
-
더 예쁜 표를 원하면
pip install rich해두세요(없어도 동작). -
컬러 끄기:
-
일시:
today실행 시 자동(현재는 파이썬 스크립트가 실행될 때--no-color전달 옵션이 없음) → 직접 테스트하려면today.csh에서--no-color를 today.py 호출 뒤에 붙여도 됩니다. -
영구:
NO_COLOR=1환경변수 설정.
-
원하시면 today.csh에서 --no-color를 전달하도록 바꾸거나, 라벨/경로 너비 고정값을 조정하는 옵션도 바로 넣어드릴게요.
1. json 말고 일반인이 직관적으로 config 편집하기 좋은 포맷은 없을까 2. remove를 빼고 edit 선택 시 에디터로 config가 열리고 거기서 편집/삭제하는게 더 편할 것 같아.
좋은 아이디어예요. 정리하면:
-
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"]
-
-
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)도 붙여 드릴게요.