Drop invoice-prep-test, standardize on .env for credentials

userConfig never prompted the user at install time in testing, so all
credential-needing skills stay on .env.example + .env. Adds the missing
.env.example/.gitignore to check-my-timesheet and invoice-prep to match
invoice-checker and timesheet-checker.
This commit is contained in:
Michał Kopeć 2026-07-15 15:19:22 +02:00
parent 576a14c82f
commit da4f334a0c
10 changed files with 44 additions and 515 deletions

View file

@ -11,12 +11,6 @@
"description": "Rewrite and humanize text to remove AI writing patterns and match your own voice.",
"version": "1.0.0"
},
{
"name": "invoice-prep-test",
"source": "./plugins/invoice-prep-test",
"description": "TEST: copy of invoice-prep that asks for the Tempo API token via plugin userConfig at install time.",
"version": "1.0.0"
},
{
"name": "ivona",
"source": "./plugins/ivona",

View file

@ -10,7 +10,6 @@ writing, and invoice/timesheet checks. Each skill is packaged as its own install
| `ghost-writer` | Rewrite and humanize text to remove AI writing patterns and match your own voice. |
| `hr-manager` | Run mock job interviews and produce a scored assessment with a prioritised learning plan. |
| `interview` | Run a structured interview and save the captured knowledge to a markdown file. |
| `invoice-prep-test` | **Test plugin.** Copy of `invoice-prep` that asks for the Tempo API token via plugin `userConfig` at install time instead of `.env`. |
| `ivona` | Check KSeF invoices against contractors/white-list and audit team timesheet completion via Tempo. |
| `meeting-notes` | Interview a participant about a meeting and produce structured meeting notes. |
| `meeting-prep` | Generate a prioritised list of questions for an upcoming meeting based on wiki knowledge gaps. |
@ -34,25 +33,13 @@ bare `owner/repo` shorthand won't resolve — use the full clone URL instead:
/plugin marketplace add https://git.wierzbowa.cloud/Cloud_Drift/skills-marketplace.git
```
## Credential setup — under test
## Credential setup
Most plugins that call an external API (Tempo, Jira, KSeF) currently read credentials
from a `.env` file the user creates from `.env.example`. `invoice-prep-test` is a
one-off experiment to see whether Claude Code's plugin `userConfig` mechanism — which
prompts for a value at install/enable time and can mark it `sensitive` — actually
reaches a skill's script as an environment variable
(`CLAUDE_PLUGIN_OPTION_TEMPO_API_TOKEN`) when that script runs via the Bash tool.
To test:
```bash
/plugin install invoice-prep-test@skills-marketplace
```
You should be prompted for a "Tempo API Token" during install. Then ask Claude
something like "what should I put on my invoice for last month" and check the
script's printed "Tempo token source" line — it reports whether it used the
plugin-provided value or fell back to `.env`/plain env. Once we know which
mechanism works, we'll standardize the rest of the credential-needing plugins
(`ivona`, `timesheet`) on it.
Plugins that call an external API (Tempo, Jira, KSeF) read credentials from a `.env`
file the user creates from each skill's `.env.example`. We tested Claude Code's plugin
`userConfig` mechanism (prompting for a credential at install time) as an alternative —
in practice it did not prompt the user at all, so we're sticking with `.env` files
across all plugins.
## Licensing

View file

@ -1,13 +0,0 @@
{
"name": "invoice-prep-test",
"version": "1.0.0",
"description": "TEST plugin: copy of invoice-prep that asks for the Tempo API token via plugin userConfig at install time, instead of a .env file.",
"userConfig": {
"tempo_api_token": {
"type": "string",
"title": "Tempo API Token",
"description": "Tempo read token, used to fetch your worklogs. Generate at Tempo → Settings → API Integration.",
"sensitive": true
}
}
}

View file

@ -1,17 +0,0 @@
# Invoice Prep (test) — credentials template
# Copy this file to .env and fill in the values.
# .env is gitignored and never committed.
#
# NOTE: the Tempo API token is intentionally NOT here for this test — it's
# meant to be supplied via the plugin's install-time configuration prompt
# (userConfig.tempo_api_token in plugin.json), so we can verify whether that
# reaches this skill's script as CLAUDE_PLUGIN_OPTION_TEMPO_API_TOKEN.
# Your Atlassian / Jira Cloud URL
JIRA_BASE_URL=https://your-company.atlassian.net
# The email address you use to log into Jira
JIRA_EMAIL=your.email@company.com
# Jira personal API token — generate at: https://id.atlassian.com → Security → API tokens
JIRA_API_TOKEN=

View file

@ -1,94 +0,0 @@
---
name: invoice-prep-test
description: >
TEST VERSION of invoice-prep — show what to put on an invoice for a selected
month by checking the user's own Tempo timesheets. Lists every Jira project
code and the total hours logged against it. Warns if there are missing hours
or days with no logged time. This variant requests the Tempo API token via
the plugin's install-time configuration instead of a .env file.
TRIGGER when user asks what to put on an invoice, what to invoice, invoice
preparation, timesheet summary for invoicing, or similar phrases.
Trigger phrases: "what should I put on an invoice", "prepare my invoice",
"invoice for [month]", "what to invoice for", "check my TS for invoice".
---
# Invoice Preparation (test — plugin userConfig for Tempo token)
This is a test copy of the `invoice-prep` skill. It exists to check whether a
credential declared in the plugin's `userConfig` (prompted for when the plugin
is installed/enabled) actually reaches this skill's script as an environment
variable — `CLAUDE_PLUGIN_OPTION_TEMPO_API_TOKEN` — when the script is run via
the Bash tool. Everything else about the skill is unchanged from `invoice-prep`.
Use this skill when someone wants to know what project codes and hours to put on their invoice for a given month. It reads their own Tempo worklogs, groups hours by Jira project, and warns about any missing time.
**In Claude Code, you run the pipeline automatically.** The user provides a month — you handle everything else.
## Prerequisites
| Variable | Purpose | Where it comes from |
|---|---|---|
| Tempo API Token | Fetch worklogs from Tempo | Prompted at plugin install/enable time (`userConfig.tempo_api_token`). Falls back to `TEMPO_API_TOKEN` in `.env` if the plugin-provided value isn't available. |
| `JIRA_BASE_URL` | Resolve the user's account ID | `.env` file in the project root |
| `JIRA_EMAIL` | Identifies whose timesheets to read | `.env` file in the project root |
| `JIRA_API_TOKEN` | Authenticate with Jira | `.env` file in the project root |
Copy `.env.example` to `.env` in this skill folder (or the project root) and fill in the three Jira values. The Tempo token should NOT go in `.env` for this test — it's meant to come from the plugin's install-time prompt, so we can see whether that mechanism actually works.
The script prints which source it used for the Tempo token (`CLAUDE_PLUGIN_OPTION_TEMPO_API_TOKEN` vs `.env` fallback) — check that line in the output to see the test result.
## Extracting the month
Convert natural language to `YYYY-MM` before running:
| User says | CLI value |
|---|---|
| "last month" | Previous calendar month |
| "this month" | Current calendar month |
| "June", "June 2026" | `2026-06` |
| "May" | `2026-05` (current year) |
If no month is specified, ask: "Which month should I check? (e.g. last month, June 2026)"
## Workflow
**Step 1 — Run the script:**
```bash
python3 {SKILL_DIR}/scripts/run.py \
--month {YYYY-MM} \
--output-dir ./timesheet-output
```
This resolves the user's Jira account, fetches their Tempo worklogs for the full calendar month, groups hours by Jira project code, checks completeness against the Polish working calendar, and writes `invoice_prep_{YYYY-MM}.json` to `./timesheet-output/`.
Report the printed "Tempo token source" line back to the user along with the results — that's the point of this test.
**Step 2 — Present results.** Read the output JSON and show the user:
1. A table of project codes and hours — formatted ready to copy onto an invoice.
Include **every project in the `projects` array**. Do NOT label any project as "non-billable" or omit it from the table — the script already excludes ignored issues; everything remaining is billable:
```
| Project | Hours |
| IAA | 45.5 |
| PROJ | 22.0 |
```
Below the table, note ignored issues and their hours as a parenthetical: `(INTERNAL-1: 40h ignored per config.)` — do not mix them into the billable table.
2. The **total billable hours** from `total_billable_hours` (not `total_logged_hours`).
3. If `missing_hours > 0`: a clear warning showing:
- How many hours are missing vs the expected total for the month
- Which specific dates have no logged time (up to 10; if more, show count)
- Reminder to complete the timesheet before issuing the invoice
4. If `total_logged_hours == 0`: warn that no hours were found at all for the month.
## Guardrails
- Never fabricate or estimate hours. All data comes from Tempo.
- If `JIRA_EMAIL` is not set, stop and tell the user.
- If any script step fails, show the error and stop.
- Always remind the user that incomplete timesheets will cause Jira compliance failures when the invoice is checked.

View file

@ -1,366 +0,0 @@
#!/usr/bin/env python3
"""
Generate invoice line items from the user's own Tempo worklogs for a given month.
Groups hours by Jira project code and reports missing time vs the Polish
working calendar.
"""
from __future__ import annotations
import argparse
import base64
import calendar
import json
import os
import re
import sys
import urllib.error
import urllib.request
from datetime import date, timedelta
from pathlib import Path
from typing import Any
# ── .env loading ──────────────────────────────────────────────────────────────
def load_dotenv(*paths: Path) -> Path | None:
for path in paths:
if not path.exists():
continue
for raw in path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip().strip('"').strip("'")
if key and key not in os.environ:
os.environ[key] = value
return path
return None
# ── Period helpers ────────────────────────────────────────────────────────────
def parse_month(month_str: str) -> tuple[date, date]:
"""Parse 'YYYY-MM' into (first_day, last_day) of that month."""
s = month_str.strip()
if not re.fullmatch(r"\d{4}-\d{2}", s):
raise ValueError(f"Invalid month '{month_str}'. Use YYYY-MM.")
year, month = int(s[:4]), int(s[5:7])
first = date(year, month, 1)
last = date(year, month, calendar.monthrange(year, month)[1])
return first, last
# ── Public holidays ───────────────────────────────────────────────────────────
def get_public_holidays(country: str, year: int) -> set[date]:
url = f"https://date.nager.at/api/v3/PublicHolidays/{year}/{country.upper()}"
req = urllib.request.Request(url, headers={"User-Agent": "invoice-prep/1.0"})
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return {date.fromisoformat(item["date"]) for item in json.loads(resp.read())}
except Exception as exc:
print(f"Warning: could not fetch holidays for {country} {year}: {exc}", file=sys.stderr)
return set()
def working_days(start: date, end: date, holidays: set[date]) -> list[date]:
result, cur = [], start
while cur <= end:
if cur.weekday() < 5 and cur not in holidays:
result.append(cur)
cur += timedelta(days=1)
return result
# ── Jira account lookup ───────────────────────────────────────────────────────
def resolve_account_id(email: str, jira_base_url: str,
jira_email: str, jira_token: str) -> str:
creds = base64.b64encode(f"{jira_email}:{jira_token}".encode()).decode()
headers = {"Authorization": f"Basic {creds}", "Accept": "application/json"}
url = f"{jira_base_url.rstrip('/')}/rest/api/3/user/search?query={email}&maxResults=5"
req = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
users = json.loads(resp.read())
except urllib.error.HTTPError as exc:
if exc.code == 401:
print("Error: Jira API returned 401. Check JIRA_EMAIL and JIRA_API_TOKEN.",
file=sys.stderr)
sys.exit(1)
raise RuntimeError(f"Jira API {exc.code}") from exc
match = next(
(u for u in users if u.get("emailAddress", "").lower() == email.lower()),
None,
)
if not match:
print(f"Error: Jira account not found for {email}.", file=sys.stderr)
sys.exit(1)
return match["accountId"]
# ── Tempo worklogs ────────────────────────────────────────────────────────────
_PROJECT_CODE_RE = re.compile(r"\b([A-Z][A-Z0-9]{1,9})-\d+\b")
def load_jira_ignore(path: Path) -> set[str]:
"""Load issue keys to ignore from a markdown table in jira_ignore.md."""
if not path.exists():
return set()
ignored: set[str] = set()
in_table = False
header_seen = False
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not (line.startswith("|") and line.endswith("|")):
in_table = False
header_seen = False
continue
cells = [c.strip() for c in line.strip("|").split("|")]
if not header_seen:
# First row is header or separator
if all(re.fullmatch(r":?-{3,}:?", c) for c in cells if c):
continue # separator row
header_seen = True
in_table = True
continue
if all(re.fullmatch(r":?-{3,}:?", c) for c in cells if c):
continue # separator row
if in_table and cells and cells[0]:
ignored.add(cells[0].strip().upper())
return ignored
def extract_project_code(issue_key: str) -> str | None:
"""Extract project code from a Jira issue key, e.g. 'IAA' from 'IAA-42'."""
m = _PROJECT_CODE_RE.match(issue_key.strip())
return m.group(1) if m else None
def fetch_worklogs(account_id: str, start: date, end: date,
token: str, base_url: str = "https://api.tempo.io/4") -> list[dict]:
"""Return all Tempo worklog entries for the account in [start, end]."""
results: list[dict] = []
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
next_url: str | None = (
f"{base_url}/worklogs/user/{account_id}"
f"?from={start}&to={end}&limit=1000"
)
while next_url:
req = urllib.request.Request(next_url, headers=headers)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
data = json.loads(resp.read())
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
if exc.code == 401:
print("Error: Tempo API returned 401. Check TEMPO_API_TOKEN.", file=sys.stderr)
sys.exit(1)
raise RuntimeError(f"Tempo API {exc.code}: {body[:200]}") from exc
results.extend(data.get("results", []))
next_url = data.get("metadata", {}).get("next")
return results
def resolve_issue_keys(worklogs: list[dict], jira_email: str,
jira_token: str) -> dict[int, str]:
"""
Resolve Jira issue IDs keys by calling each issue's self URL directly.
Uses the self URL already present in each Tempo worklog entry.
"""
creds = base64.b64encode(f"{jira_email}:{jira_token}".encode()).decode()
headers = {"Authorization": f"Basic {creds}", "Accept": "application/json"}
seen: dict[int, str] = {}
for wl in worklogs:
issue = wl.get("issue", {})
issue_id = issue.get("id")
if not issue_id or int(issue_id) in seen:
continue
self_url = issue.get("self", "")
if not self_url:
continue
# Swap api/2 → api/3 if needed; append fields filter
url = re.sub(r"/rest/api/\d+/", "/rest/api/3/", self_url) + "?fields=key"
req = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read())
seen[int(issue_id)] = data.get("key", "")
except Exception as exc:
print(f"Warning: could not resolve issue {issue_id}: {exc}", file=sys.stderr)
return seen
# ── Main ──────────────────────────────────────────────────────────────────────
def run(args: argparse.Namespace) -> int:
skill_dir = Path(__file__).parent.parent
loaded = load_dotenv(Path.cwd() / ".env", skill_dir / ".env")
if loaded:
print(f"Loaded credentials from {loaded}")
# TEST: tempo_api_token is requested via plugin.json userConfig at install
# time. Claude Code is expected to inject it as CLAUDE_PLUGIN_OPTION_TEMPO_API_TOKEN.
# Fall back to a plain TEMPO_API_TOKEN (env/.env) so this still works if that
# injection doesn't reach this script.
plugin_option_token = os.environ.get("CLAUDE_PLUGIN_OPTION_TEMPO_API_TOKEN", "")
env_token = os.environ.get("TEMPO_API_TOKEN", "")
tempo_token = plugin_option_token or env_token
if plugin_option_token:
print("Tempo token source: CLAUDE_PLUGIN_OPTION_TEMPO_API_TOKEN (plugin userConfig)")
elif env_token:
print("Tempo token source: TEMPO_API_TOKEN (env/.env) — plugin userConfig var was not set")
else:
print("Tempo token source: none found")
jira_base_url = os.environ.get("JIRA_BASE_URL", "")
jira_email = os.environ.get("JIRA_EMAIL", "")
jira_token = os.environ.get("JIRA_API_TOKEN", "")
missing = [n for n, v in [
("TEMPO_API_TOKEN (via userConfig or env)", tempo_token), ("JIRA_BASE_URL", jira_base_url),
("JIRA_EMAIL", jira_email), ("JIRA_API_TOKEN", jira_token),
] if not v]
if missing:
print(f"Error: missing credentials: {', '.join(missing)}", file=sys.stderr)
return 1
try:
start, end = parse_month(args.month)
except ValueError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
# Load ignore list
ignore_path = Path.cwd() / "jira_ignore.md"
ignored_keys = load_jira_ignore(ignore_path)
if ignored_keys:
print(f"Ignoring issues: {', '.join(sorted(ignored_keys))}")
print(f"Month: {args.month} ({start} {end})")
print(f"User: {jira_email}")
# Resolve account ID
print("Resolving Jira account...")
account_id = resolve_account_id(jira_email, jira_base_url, jira_email, jira_token)
print(f" {jira_email}{account_id}")
# Working days (Polish calendar)
print("Fetching public holidays...")
holidays = get_public_holidays("PL", start.year)
wdays = working_days(start, end, holidays)
expected_hours = len(wdays) * 8.0
print(f" Working days: {len(wdays)} Expected hours: {expected_hours:.0f}h")
# Fetch Tempo worklogs
print("Fetching Tempo worklogs...")
worklogs = fetch_worklogs(account_id, start, end, tempo_token)
print(f" Worklogs fetched: {len(worklogs)}")
# Resolve issue IDs → keys via each issue's self URL (Tempo v4 omits the key)
unique_ids = {int(wl["issue"]["id"]) for wl in worklogs if wl.get("issue", {}).get("id")}
issue_key_map: dict[int, str] = {}
if unique_ids:
print(f" Resolving {len(unique_ids)} unique issue key(s) from Jira...")
issue_key_map = resolve_issue_keys(worklogs, jira_email, jira_token)
print(f" Resolved: {len(issue_key_map)}")
# Group hours by project code and by date, skipping ignored issues
project_hours: dict[str, float] = {}
hours_by_date: dict[str, float] = {}
unmatched_hours = 0.0
ignored_hours = 0.0
for wl in worklogs:
issue_id = int(wl.get("issue", {}).get("id", 0))
issue_key = issue_key_map.get(issue_id, "")
hours = wl.get("timeSpentSeconds", 0) / 3600
wl_date = wl.get("startDate", "")
if issue_key.upper() in ignored_keys:
ignored_hours += hours
# Still count toward date totals so missing-day detection stays accurate
if wl_date:
hours_by_date[wl_date] = hours_by_date.get(wl_date, 0.0) + hours
continue
code = extract_project_code(issue_key)
if code:
project_hours[code] = project_hours.get(code, 0.0) + hours
else:
unmatched_hours += hours
if wl_date:
hours_by_date[wl_date] = hours_by_date.get(wl_date, 0.0) + hours
# Sort projects by hours descending
projects = sorted(
[{"code": k, "hours": round(v, 2)} for k, v in project_hours.items()],
key=lambda x: x["hours"],
reverse=True,
)
total_logged = round(sum(project_hours.values()) + unmatched_hours + ignored_hours, 2)
total_billable = round(sum(project_hours.values()) + unmatched_hours, 2)
missing_hours = round(max(0.0, expected_hours - total_logged), 2)
# Find working days with no logged time
days_missing = [
d.isoformat() for d in wdays
if hours_by_date.get(d.isoformat(), 0.0) == 0.0
]
output = {
"month": args.month,
"user_email": jira_email,
"account_id": account_id,
"working_days": len(wdays),
"expected_hours": expected_hours,
"total_logged_hours": total_logged,
"total_billable_hours": total_billable,
"ignored_hours": round(ignored_hours, 2),
"ignored_keys": sorted(ignored_keys),
"missing_hours": missing_hours,
"unmatched_hours": round(unmatched_hours, 2),
"days_with_no_hours": days_missing,
"projects": projects,
}
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
out_path = output_dir / f"invoice_prep_{args.month}.json"
out_path.write_text(json.dumps(output, indent=2, ensure_ascii=False), encoding="utf-8")
print(f"Wrote {out_path}")
# Print quick summary
print(f"\nProjects:")
for p in projects:
print(f" {p['code']:12s} {p['hours']:.2f}h")
if unmatched_hours:
print(f" (no project) {unmatched_hours:.2f}h")
if ignored_hours:
print(f" (ignored) {ignored_hours:.2f}h ({', '.join(sorted(ignored_keys))})")
print(f"\nBillable total: {total_billable:.2f}h")
print(f"Total logged: {total_logged:.2f}h / {expected_hours:.0f}h expected")
if missing_hours > 0:
print(f"⚠ Missing: {missing_hours:.2f}h ({len(days_missing)} day(s) with no time logged)")
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Generate invoice line items from Tempo worklogs."
)
parser.add_argument("--month", required=True, help="Month to check: YYYY-MM")
parser.add_argument("--output-dir", required=True,
help="Directory for invoice_prep_YYYY-MM.json")
return parser
if __name__ == "__main__":
sys.exit(run(build_parser().parse_args()))

View file

@ -0,0 +1,21 @@
# Check My Timesheet — credentials template
# Copy this file to .env (in the project root) and fill in the values.
# .env is gitignored and never committed.
# Tempo API token — generate at: Tempo → Settings → API Integration
TEMPO_API_TOKEN=
# Your Atlassian / Jira Cloud URL
JIRA_BASE_URL=https://your-company.atlassian.net
# The email address you use to log into Jira
JIRA_EMAIL=your.email@company.com
# Jira personal API token — generate at: https://id.atlassian.com → Security → API tokens
JIRA_API_TOKEN=
# Country code for the public holiday calendar (optional, defaults to PL)
MY_TIMESHEET_COUNTRY=PL
# Expected working hours per day (optional, defaults to 8)
MY_TIMESHEET_HOURS_PER_DAY=8

View file

@ -0,0 +1,16 @@
# Invoice Prep — credentials template
# Copy this file to .env (in the project root) and fill in the values.
# .env is gitignored and never committed.
# Same credentials as check-my-timesheet — one .env can be shared by both.
# Tempo API token — generate at: Tempo → Settings → API Integration
TEMPO_API_TOKEN=
# Your Atlassian / Jira Cloud URL
JIRA_BASE_URL=https://your-company.atlassian.net
# The email address you use to log into Jira
JIRA_EMAIL=your.email@company.com
# Jira personal API token — generate at: https://id.atlassian.com → Security → API tokens
JIRA_API_TOKEN=

View file

@ -0,0 +1 @@
.env