diff --git a/SKILL.md b/SKILL.md index fc23394..b19e6e0 100644 --- a/SKILL.md +++ b/SKILL.md @@ -81,6 +81,50 @@ Connection + repo names live in `config/gitea.env` (`SKILL_REPO`, `LIBRARY_REPO` need no per-session token. If the host is unreachable, the git steps fail clearly and write nothing. +## Who's running this (operator identity) + +The whole org shares one Claude account and one Gitea token, so the skill can't tell who's +running it on its own. Instead it records the **operator's name** on everything it pushes, and +asks **only once per person**. Establish the identity at the very start of a run: + +1. Read `~/.library-manager-id` on the operator's machine (via the desktop bridge): + `cat ~/.library-manager-id`. +2. **If it exists** (JSON like `{"name":"Priya Sharma","email":"priya@vecmocon.com"}`) → use it, + **don't ask**. +3. **If it does NOT exist → this person isn't onboarded yet, so ask once** (do this even though + the config carries a default, so a teammate is never silently logged as "admin"): + > "First time using the library skill on this machine — what name should your library + > changes be recorded under? (If you're the admin, just enter `admin`.) And your email, + > if you have one." + + Then save it so it's remembered forever: + `echo '{"name":"","email":""}' > ~/.library-manager-id`. Every later run finds + it in step 1 and never asks again — the admin answers `admin` once, each teammate answers + their own name once, and from then on everyone is attributed correctly with no prompt. +4. **Only if you genuinely can't ask** (an unattended / scheduled run, or the machine isn't + reachable) fall back to the config `OPERATOR` default so the run isn't blocked — and say in + your summary that attribution used the default rather than a confirmed person. + +Then carry that identity through the run: + +- pass `--author " <>"` to every `push-part` / `push-skill` / `commit-push`, and +- pass `--by ""` to `append_parameter`. + +(Equivalent alternatives the scripts also read: `export LM_AUTHOR_NAME=... LM_AUTHOR_EMAIL=...` +for the session, or drop the same `~/.library-manager-id` file in the container home.) + +**Identity precedence** (first one that's set wins): `--author` flag → `LM_AUTHOR_*` env → +per-person `~/.library-manager-id` → the per-install **`OPERATOR`** default in +`config/gitea.env`. So this admin install has `OPERATOR=admin`, meaning **every run/push here +is recorded as "admin" automatically**, with no file or prompt needed. A member's own +`~/.library-manager-id` (or an explicit `--author`) overrides that default with their real name. + +This stamps the operator onto three things: the **commit author** (shown in `git log`, the +Gitea commit page, and `git blame`), the **commit message** (it ends with `(by )`, so the +name shows right in Gitea's activity feed), and a **By** column in the changelog. Honest limit: +the top-line "*X* pushed to main" in Gitea's activity still shows the **shared token owner** — +only giving each person their own Gitea token changes that bottom layer. + ## Workflow Run these in order. Each `python`/`bash` command is a helper in `scripts/`. @@ -135,7 +179,7 @@ Check whether that typeid has a sheet in `assets/template/template.xlsx`. ```bash python scripts/append_parameter.py --typeid \ --param "New Parameter(unit)" [--param "Another(unit)"] \ - --desc "why these were added" + --desc "why these were added" --by "" ``` This appends the column(s) at the end of that typeid's sheet, **bumps that typeid's @@ -229,7 +273,8 @@ datasheet (name it `_data.`), the symbol, and the footprint. ### 8. Push to the library repo, under the part's Class ```bash -python scripts/gitea_components.py push-part --folder / --typeid +python scripts/gitea_components.py push-part --folder / --typeid \ + --author " <>" ``` This places the folder at `components///` — creating the Class folder if it @@ -296,7 +341,8 @@ push the skill's own files to the skill repo with `push-skill` **automatically** confirmation): ```bash -python scripts/gitea_components.py push-skill --message "Sync skill files + changelog" +python scripts/gitea_components.py push-skill --author " <>" \ + --message "Sync skill files + changelog" ``` `push-skill` clones the skill repo, copies the skill files in with the **`GIT_TOKEN` blanked diff --git a/assets/CHANGELOG.xlsx b/assets/CHANGELOG.xlsx index facf0d2..c313ad6 100644 Binary files a/assets/CHANGELOG.xlsx and b/assets/CHANGELOG.xlsx differ diff --git a/assets/template/template.xlsx b/assets/template/template.xlsx index def408c..c1bd423 100644 Binary files a/assets/template/template.xlsx and b/assets/template/template.xlsx differ diff --git a/assets/template/versions.json b/assets/template/versions.json index 86f7311..8e899a1 100644 --- a/assets/template/versions.json +++ b/assets/template/versions.json @@ -220,8 +220,8 @@ "template_version": 1 }, "ISO": { - "skill_version": 1, - "template_version": 1 + "skill_version": 2, + "template_version": 2 }, "JFET": { "skill_version": 1, diff --git a/config/gitea.env b/config/gitea.env index c51bcf8..e29c3ee 100644 --- a/config/gitea.env +++ b/config/gitea.env @@ -3,7 +3,7 @@ # Use a token scoped to these repos (repository: write) and rotate periodically. GIT_HOST=gitea.vecmocon.com GIT_USER=nitishKumar -GIT_TOKEN= +GIT_TOKEN=451bff1dc32202cbc0a371f8e5645079466d2120 # Target repos — TWO now (set these to the exact repo names on your Gitea): # SKILL_REPO : holds this skill's own files (SKILL.md, scripts, assets, ...). # LIBRARY_REPO : holds components, one folder per Class (Diode, IC, ...); inside each, @@ -11,3 +11,7 @@ GIT_TOKEN= # footprint }. (Replaces the old DFS + Parameters repos.) SKILL_REPO=nitishKumar/skill LIBRARY_REPO=nitishKumar/library +# Default operator for THIS install — who shows as the author / in the changelog "By" when no +# per-person identity (~/.library-manager-id) is set. This install is the admin's, so: +OPERATOR=admin +OPERATOR_EMAIL=admin@vecmocon.com diff --git a/scripts/__pycache__/common.cpython-311.pyc b/scripts/__pycache__/common.cpython-311.pyc index 9ff1cf6..1fff584 100644 Binary files a/scripts/__pycache__/common.cpython-311.pyc and b/scripts/__pycache__/common.cpython-311.pyc differ diff --git a/scripts/append_parameter.py b/scripts/append_parameter.py index 263f2dc..1567d63 100644 --- a/scripts/append_parameter.py +++ b/scripts/append_parameter.py @@ -21,9 +21,9 @@ import argparse, datetime, os import openpyxl from copy import copy from openpyxl.styles import Font, PatternFill, Border, Side, Alignment -from common import (TEMPLATE_XLSX, CHANGELOG_XLSX, bump_versions) +from common import (TEMPLATE_XLSX, CHANGELOG_XLSX, CHANGELOG_HEADERS, + bump_versions, resolve_operator) -CHANGELOG_HEADERS = ["Date", "Typeid", "Skill Version", "Template Version", "Description"] GREEN = "B6D7A8"; GRAY = "BFBFBF" @@ -74,16 +74,17 @@ def _new_changelog_book(): cell.fill = PatternFill("solid", fgColor=GREEN) cell.border = _thin() cell.alignment = Alignment(horizontal="center", vertical="center") - for col, w in zip("ABCDE", (18, 10, 14, 16, 60)): + for col, w in zip("ABCDEF", (18, 10, 14, 16, 50, 22)): ws.column_dimensions[col].width = w ws.freeze_panes = "A2" return wb -def write_changelog(typeid, added, old, new, desc, when=None): +def write_changelog(typeid, added, old, new, desc, by="", when=None): """Append one row to the Excel changelog for a typeid's template/version change. - Columns: Date | Typeid | Skill Version | Template Version | Description. The version - columns show the NEW versions; Description is the user's note (or the added params).""" + Columns: Date | Typeid | Skill Version | Template Version | Description | By. The version + columns show the NEW versions; Description is the note (or the added params); By is who + made the change (the operator).""" when = when or datetime.datetime.now() detail = desc or ("Added parameter(s): " + ", ".join(added)) if os.path.exists(CHANGELOG_XLSX): @@ -93,7 +94,7 @@ def write_changelog(typeid, added, old, new, desc, when=None): wb = _new_changelog_book() ws = wb.active row = [when.strftime("%Y-%m-%d %H:%M"), typeid, - f"v{new['skill_version']}", f"v{new['template_version']}", detail] + f"v{new['skill_version']}", f"v{new['template_version']}", detail, by or ""] r = ws.max_row + 1 for c, v in enumerate(row, start=1): cell = ws.cell(r, c, v) @@ -110,13 +111,16 @@ def main(): ap.add_argument("--param", required=True, action="append", help="new column header; repeat for several") ap.add_argument("--desc", default="", help="what/why - goes in the changelog") + ap.add_argument("--by", default="", help="who made this change (name); recorded in the 'By' " + "column. If omitted, the operator identity is resolved automatically.") ap.add_argument("--template", default=TEMPLATE_XLSX) a = ap.parse_args() + by = a.by or (resolve_operator()[0] or "") added = append_params(a.template, a.typeid, a.param) old, new = bump_versions(a.typeid) - cl = write_changelog(a.typeid, added, old, new, a.desc) - print(f"Added to {a.typeid}: {', '.join(added)}") + cl = write_changelog(a.typeid, added, old, new, a.desc, by=by) + print(f"Added to {a.typeid}: {', '.join(added)}" + (f" (by {by})" if by else "")) print(f"{a.typeid}: template v{old['template_version']}->v{new['template_version']}, " f"skill v{old['skill_version']}->v{new['skill_version']}") print(f"Changelog updated: {cl}") diff --git a/scripts/common.py b/scripts/common.py index b2f5c20..68243ec 100644 --- a/scripts/common.py +++ b/scripts/common.py @@ -155,7 +155,8 @@ def init_versions(force=False): # ---------------------------------------------------------------- changelog (read) -CHANGELOG_HEADERS = ["Date", "Typeid", "Skill Version", "Template Version", "Description"] +# "By" = who made the change (the operator). See operator identity below. +CHANGELOG_HEADERS = ["Date", "Typeid", "Skill Version", "Template Version", "Description", "By"] def read_changelog(): @@ -168,9 +169,9 @@ def read_changelog(): for r in ws.iter_rows(min_row=2, values_only=True): if not r or all(c is None for c in r): continue - r = list(r) + [None] * (5 - len(r)) + r = list(r) + [None] * (len(CHANGELOG_HEADERS) - len(r)) # pad old 5-col rows out.append({"date": r[0], "typeid": r[1], "skill": r[2], - "template": r[3], "description": r[4]}) + "template": r[3], "description": r[4], "by": r[5]}) return out @@ -180,6 +181,61 @@ def changelog_for_typeid(typeid): return [r for r in read_changelog() if str(r["typeid"]) == str(typeid)] +# ---------------------------------------------------------------- operator identity + +# Where each person's "who am I" is stored, on their OWN machine. The skill asks once, writes +# it here, and reads it silently on every later run so nobody has to retype their name. Because +# it lives on each person's computer (not in the shared config), it stays per-person even though +# the Claude account and the Gitea token are shared. +IDENTITY_FILE = os.path.expanduser("~/.library-manager-id") + + +def parse_author(author): + """'Name ' or 'Name' -> (name, email). Returns (None, None) for empty input.""" + if not author: + return None, None + m = re.match(r"^\s*(.*?)\s*<(.*?)>\s*$", str(author)) + if m: + return m.group(1).strip() or None, m.group(2).strip() + return str(author).strip() or None, "" + + +def _config_operator(): + """(name, email) from the OPERATOR / OPERATOR_EMAIL lines of config/gitea.env — the + per-install default identity (e.g. the admin's install sets OPERATOR=admin).""" + cfg = os.path.join(SKILL_ROOT, "config", "gitea.env") + name = email = None + if os.path.exists(cfg): + for line in open(cfg, encoding="utf-8"): + line = line.strip() + if line.startswith("OPERATOR="): + name = line.split("=", 1)[1].strip() + elif line.startswith("OPERATOR_EMAIL="): + email = line.split("=", 1)[1].strip() + return (name or None), (email or "") + + +def resolve_operator(author_arg=None): + """Return (name, email) for the person running this, trying in order: + (1) an explicit --author, (2) the LM_AUTHOR_NAME/LM_AUTHOR_EMAIL env vars, (3) the + per-person identity file ~/.library-manager-id, (4) the per-install OPERATOR default in + config/gitea.env. (None, None) if none set — callers fall back to the generic bot.""" + name, email = parse_author(author_arg) + if name: + return name, email + name = os.environ.get("LM_AUTHOR_NAME") + if name: + return name, os.environ.get("LM_AUTHOR_EMAIL", "") + if os.path.exists(IDENTITY_FILE): + try: + d = json.load(open(IDENTITY_FILE, encoding="utf-8")) + if d.get("name"): + return d.get("name"), d.get("email", "") + except Exception: + pass + return _config_operator() + + if __name__ == "__main__": import sys if len(sys.argv) > 1 and sys.argv[1] == "init": diff --git a/scripts/fill_templates.py b/scripts/fill_templates.py index a797194..c17973b 100644 --- a/scripts/fill_templates.py +++ b/scripts/fill_templates.py @@ -53,7 +53,7 @@ def add_version_history(wb, typeid): ws = wb.create_sheet(VERSION_SHEET) s = Side(style="thin", color=GRAY) border = Border(left=s, right=s, top=s, bottom=s) - headers = ["Date", "Skill Version", "Template Version", "Description"] + headers = ["Date", "Skill Version", "Template Version", "Description", "By"] for c, h in enumerate(headers, start=1): cell = ws.cell(1, c, h) cell.font = Font(name="Calibri", bold=True) @@ -62,12 +62,13 @@ def add_version_history(wb, typeid): cell.alignment = Alignment(horizontal="center", vertical="center") for row in rows: r = ws.max_row + 1 - vals = [row["date"], _vtrans(row["skill"]), _vtrans(row["template"]), row["description"]] + vals = [row["date"], _vtrans(row["skill"]), _vtrans(row["template"]), + row["description"], row.get("by") or ""] for c, v in enumerate(vals, start=1): cell = ws.cell(r, c, v) cell.border = border cell.alignment = Alignment(vertical="center", wrap_text=(c == 4), horizontal="left") - for col, w in zip("ABCD", (18, 16, 18, 60)): + for col, w in zip("ABCDE", (18, 16, 18, 50, 22)): ws.column_dimensions[col].width = w ws.freeze_panes = "A2" return True diff --git a/scripts/gitea_components.py b/scripts/gitea_components.py index 39871cc..0027631 100644 --- a/scripts/gitea_components.py +++ b/scripts/gitea_components.py @@ -44,7 +44,8 @@ nothing is written. import argparse, os, re, shutil, subprocess, sys, tempfile sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from common import (class_folder, load_taxonomy, mpn_make_prefix, - SKILL_ROOT, TEMPLATE_XLSX, VERSIONS_JSON, CHANGELOG_XLSX) + SKILL_ROOT, TEMPLATE_XLSX, VERSIONS_JSON, CHANGELOG_XLSX, + CHANGELOG_HEADERS, resolve_operator) CFG_DEFAULT = os.path.join(os.path.dirname(__file__), "..", "config", "gitea.env") @@ -89,9 +90,15 @@ def clone(env, dest, repo=None): return dest -def commit_push(root, token, msg): - for args in (["config", "user.email", "datasheet-bot@local"], - ["config", "user.name", "library-manager"], +def commit_push(root, token, msg, author=None): + """Commit + push. `author` = (name, email) of the operator; when given, the commit is + authored by that real person (visible in git log / Gitea commit view / blame). Without it, + a generic bot identity is used.""" + name, email = author or (None, None) + name = name or "library-manager" + email = email or "datasheet-bot@local" + for args in (["config", "user.email", email], + ["config", "user.name", name], ["checkout", "-B", "main"], ["add", "-A"]): subprocess.run(["git", "-C", root] + args, capture_output=True) if subprocess.run(["git", "-C", root, "diff", "--cached", "--quiet"]).returncode == 0: @@ -193,30 +200,50 @@ def cmd_place_part(env, args): print(f"placed {tag} -> {cls}/{tag}") +def _operator(args): + """(name, email) of who's running this — from --author, else env, else identity file.""" + return resolve_operator(getattr(args, "author", None)) + + +def _by(name): + return f" (by {name})" if name else "" + + def cmd_commit_push(env, args): - commit_push(args.root, env.get("GIT_TOKEN", ""), args.message or "library-manager: update components") + name, email = _operator(args) + commit_push(args.root, env.get("GIT_TOKEN", ""), + args.message or f"library-manager: update components{_by(name)}", author=(name, email)) def cmd_push_part(env, args): + name, email = _operator(args) tmp = tempfile.mkdtemp(prefix="components_") clone(env, tmp) cls, tag, _ = place_part(tmp, args.folder, args.typeid) - print(f"placed {tag} -> {cls}/{tag}") - commit_push(tmp, env.get("GIT_TOKEN", ""), args.message or f"library-manager: add {tag}") + print(f"placed {tag} -> {cls}/{tag}" + (f" (by {name})" if name else "")) + commit_push(tmp, env.get("GIT_TOKEN", ""), + args.message or f"library-manager: add {tag}{_by(name)}", author=(name, email)) # ------------------------------------------------------------------ skill repo + changelog def _changelog_rows(path): - """(workbook, sheet, [row-tuples]) for a changelog xlsx; ([]) if the file is absent.""" + """(workbook, sheet, [row-tuples]) for a changelog xlsx; ([]) if the file is absent. Rows are + padded/truncated to the canonical column count so old 5-column changelogs (no 'By') merge + cleanly with new 6-column ones.""" import openpyxl if not os.path.exists(path): return None, None, [] + n = len(CHANGELOG_HEADERS) wb = openpyxl.load_workbook(path) ws = wb["Changelog"] if "Changelog" in wb.sheetnames else wb.active - rows = [tuple("" if c is None else str(c) for c in r) - for r in ws.iter_rows(min_row=2, values_only=True)] + rows = [] + for r in ws.iter_rows(min_row=2, values_only=True): + vals = ["" if c is None else str(c) for c in r] + vals = (vals + [""] * n)[:n] + if any(v for v in vals): + rows.append(tuple(vals)) return wb, ws, rows @@ -234,6 +261,7 @@ def merge_changelog(local_path, repo_path): shutil.copy(local_path, repo_path) _, _, rows = _changelog_rows(repo_path) return len(rows) + from openpyxl.styles import Font, PatternFill repo_wb, repo_ws, repo_rows = _changelog_rows(repo_path) _, _, local_rows = _changelog_rows(local_path) seen = set(repo_rows) @@ -245,16 +273,26 @@ def merge_changelog(local_path, repo_path): seen.add(row) added += 1 merged.sort(key=lambda r: r[0]) # chronological by Date (column A) - if repo_ws.max_row > 1: # rewrite data rows, keep the header - repo_ws.delete_rows(2, repo_ws.max_row - 1) + n = len(CHANGELOG_HEADERS) + desc_col = CHANGELOG_HEADERS.index("Description") + 1 s = Side(style="thin", color="BFBFBF") border = Border(left=s, right=s, top=s, bottom=s) + if repo_ws.max_row > 1: # clear everything, rewrite header + rows + repo_ws.delete_rows(1, repo_ws.max_row) + for c, h in enumerate(CHANGELOG_HEADERS, start=1): # canonical header (migrates 5->6 cols) + cell = repo_ws.cell(1, c, h) + cell.font = Font(name="Calibri", bold=True) + cell.fill = PatternFill("solid", fgColor="B6D7A8") + cell.border = border + cell.alignment = Alignment(horizontal="center", vertical="center") for row in merged: - repo_ws.append(list(row)) - rr = repo_ws.max_row - for c in range(1, 6): - repo_ws.cell(rr, c).border = border - repo_ws.cell(rr, c).alignment = Alignment(vertical="center", wrap_text=(c == 5), horizontal="left") + rr = repo_ws.max_row + 1 + for c in range(1, n + 1): + cell = repo_ws.cell(rr, c, row[c - 1] if c - 1 < len(row) else "") + cell.border = border + cell.alignment = Alignment(vertical="center", wrap_text=(c == desc_col), horizontal="left") + repo_wb.freeze_panes = None + repo_ws.freeze_panes = "A2" repo_wb.save(repo_path) return added @@ -297,7 +335,10 @@ def cmd_push_skill(env, args): if os.path.exists(repo_cl): # sync merged history back to local shutil.copy(repo_cl, local_cl) print(f"changelog: merged (+{added} new row(s)) into the skill repo's CHANGELOG.xlsx") - commit_push(tmp, env.get("GIT_TOKEN", ""), args.message or "library-manager: sync skill files + changelog") + name, email = _operator(args) + commit_push(tmp, env.get("GIT_TOKEN", ""), + args.message or f"library-manager: sync skill files + changelog{_by(name)}", + author=(name, email)) def cmd_pull_skill(env, args): @@ -338,12 +379,14 @@ def main(): p.add_argument("--typeid", required=True); p.add_argument("--root"); p.add_argument("--local") p = sub.add_parser("commit-push"); p.add_argument("--root", required=True) - p.add_argument("--message") + p.add_argument("--message"); p.add_argument("--author", help="'Name ' of the operator") p = sub.add_parser("push-part"); p.add_argument("--folder", required=True) p.add_argument("--typeid", required=True); p.add_argument("--message") + p.add_argument("--author", help="'Name ' of the operator (who added the part)") p = sub.add_parser("push-skill") + p.add_argument("--author", help="'Name ' of the operator (who made the change)") p.add_argument("--src", default=os.path.join(os.path.dirname(__file__), ".."), help="skill directory to push (defaults to this skill)") p.add_argument("--message")