@patimweb/pi-zep 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -107,17 +107,17 @@ Other message texts observed:
107
107
  ```
108
108
  zep_setup name=work userid=patrick.weppelmann password=… mandant=zepneoimpulse
109
109
  zep_status probe=true
110
- zep_projects projekt=Julabo
110
+ zep_projects projekt=Contoso
111
111
  zep_week
112
- zep_book datum=2026-09-17 von=08:00 bis=12:00 projekt=413 vorgang=90-01 taetigkeit=co bemerkung="3447 Ladeanzeige nach Pick"
112
+ zep_book datum=2026-09-17 von=08:00 bis=12:00 projekt=413 vorgang=90-01 taetigkeit=co bemerkung="4711 Beispiel-Ticket"
113
113
  zep_book datum=2026-09-17 entries=[{von:"08:00",bis:"12:00",bemerkung:"…"},{von:"13:00",bis:"17:00",bemerkung:"…"}]
114
114
  ```
115
115
 
116
116
  `projekt` and `vorgang` accept either an id (`413`, `3284`) or part of the label
117
- (`Julabo`, `90-01`). A full example, `dryRun` first:
117
+ (`Contoso`, `90-01`). A full example, `dryRun` first:
118
118
 
119
119
  ```
120
- zep_book projekt=Julabo vorgang=90-01 datum=2026-09-17 von=08:00 bis=12:00 dryRun=true
120
+ zep_book projekt=Contoso vorgang=90-01 datum=2026-09-17 von=08:00 bis=12:00 dryRun=true
121
121
  ```
122
122
 
123
123
  ## Important behaviour
package/index.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * PHP endpoint instead of the paid ZEP REST API.
6
6
  *
7
7
  * Tools:
8
+ * - zep_doctor: diagnose config, session and reachability without spending a login attempt
8
9
  * - zep_setup: store userid/password/Mandant
9
10
  * - zep_login: log in (password, or adopt a browser tab session)
10
11
  * - zep_status: show config, log in, prove the session works
@@ -26,6 +27,7 @@
26
27
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
27
28
 
28
29
  import { loadConfig } from "./src/config.ts";
30
+ import { ZepDoctorTool } from "./src/tools/zep-doctor.ts";
29
31
  import { ZepSetupTool } from "./src/tools/zep-setup.ts";
30
32
  import { ZepLoginTool } from "./src/tools/zep-login.ts";
31
33
  import { ZepStatusTool } from "./src/tools/zep-status.ts";
@@ -38,6 +40,7 @@ import { ZepDeleteTool } from "./src/tools/zep-delete.ts";
38
40
  export default function (pi: ExtensionAPI) {
39
41
  loadConfig();
40
42
 
43
+ pi.registerTool(ZepDoctorTool);
41
44
  pi.registerTool(ZepSetupTool);
42
45
  pi.registerTool(ZepLoginTool);
43
46
  pi.registerTool(ZepStatusTool);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patimweb/pi-zep",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "ZEP time-tracking for pi coding agent — book project times (Projektzeiten) on zep-online.de over plain HTTP, without the paid ZEP API",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -51,6 +51,14 @@ export class ZepError extends Error {
51
51
  }
52
52
  }
53
53
 
54
+ export interface ZepPing {
55
+ readonly status: number;
56
+ /** The login form rendered, i.e. the server is up and reachable. */
57
+ readonly loginPageOk: boolean;
58
+ /** login.php bounced us into the app, i.e. we are already authenticated. */
59
+ readonly redirectedToApp: boolean;
60
+ }
61
+
54
62
  export interface ZepClientOptions {
55
63
  /** Called whenever a fresh session was established, so it can be cached. */
56
64
  readonly onSession?: (session: ZepSession) => void;
@@ -117,6 +125,17 @@ export class ZepClient {
117
125
  * where `noch` is the remaining attempt count before a lockout.
118
126
  */
119
127
  async login(): Promise<LoginResult> {
128
+ // Refuse before touching the network. Without this guard an absent
129
+ // password becomes the literal string "undefined" in the form body, which
130
+ // ZEP counts as a real failed attempt - and the account is locked after 5.
131
+ if (!this.account.password) {
132
+ throw new ZepError(
133
+ `No password stored for ZEP user "${this.account.userid}". Add a "password" to the ` +
134
+ "profile in ~/.pi/zep-config.json (or run zep_setup). Refusing to send an empty " +
135
+ "password, because ZEP locks the account after 5 failed attempts.",
136
+ );
137
+ }
138
+
120
139
  this.cookies.clear();
121
140
  this.clientsessid = null;
122
141
  this.requesttoken = null;
@@ -131,9 +150,16 @@ export class ZepClient {
131
150
  login: "Anmelden",
132
151
  }).toString();
133
152
 
153
+ // Match the browser form POST exactly: same-origin form submissions carry
154
+ // Referer and Origin, and ZEP's login handler is the pickiest endpoint.
155
+ const loginUrl = `${this.root}/view/login.php`;
134
156
  const res = await this.raw("/view/login.php", {
135
157
  method: "POST",
136
- headers: { "content-type": "application/x-www-form-urlencoded" },
158
+ headers: {
159
+ "content-type": "application/x-www-form-urlencoded",
160
+ referer: loginUrl,
161
+ origin: this.root,
162
+ },
137
163
  body,
138
164
  });
139
165
 
@@ -190,6 +216,22 @@ export class ZepClient {
190
216
  return this.lastLogin;
191
217
  }
192
218
 
219
+ /**
220
+ * Reachability probe used by zep_doctor. Read-only: it fetches the login
221
+ * page and reports what came back. Never call it on a client that holds a
222
+ * live session - priming a new PHPSESSID would replace the working cookie.
223
+ */
224
+ async ping(): Promise<ZepPing> {
225
+ const res = await this.raw("/view/login.php", { method: "GET" });
226
+ const body = await res.text();
227
+ const location = res.headers.get("location") ?? "";
228
+ return {
229
+ status: res.status,
230
+ loginPageOk: /id="login-form"/.test(body),
231
+ redirectedToApp: /index\.php/.test(location) && !/action=login/.test(location),
232
+ };
233
+ }
234
+
193
235
  /** Log in only when there is no usable session yet. */
194
236
  async ensureLogin(): Promise<void> {
195
237
  if (this.hasSession) return;
@@ -229,9 +271,9 @@ export class ZepClient {
229
271
  const headers = new Headers(rest.headers ?? {});
230
272
  headers.set("user-agent", USER_AGENT);
231
273
  headers.set("accept-language", "de-DE,de;q=0.9,en;q=0.8");
232
- if (rest.method && rest.method !== "GET" && !headers.has("x-requested-with")) {
233
- headers.set("x-requested-with", "XMLHttpRequest");
234
- }
274
+ // Deliberately no automatic X-Requested-With here: the browser only sends
275
+ // it on ajax.php calls, never on the login form POST. Sending it on the
276
+ // login made the request diverge from the real form.
235
277
  if (this.cookies.size > 0) {
236
278
  headers.set(
237
279
  "cookie",
@@ -449,8 +491,8 @@ export class ZepClient {
449
491
  letzterReiseOrt: "D-Office",
450
492
  ortProjektrelevant: "1",
451
493
  reise: "0",
452
- startort: "Gottmadingen Im Brühl 1",
453
- zielort: "Ismaningen Neoimpulse",
494
+ startort: "Beispielweg 1",
495
+ zielort: "Beispielort",
454
496
  fahrzeug: "Firmenwagen",
455
497
  km: "285",
456
498
  mitfahrer: "1",
@@ -297,13 +297,51 @@ function resolveDayDate(dayLabel: string, kwDate: string): string | null {
297
297
  return monday.toISOString().slice(0, 10);
298
298
  }
299
299
 
300
+ /**
301
+ * Label of a day row: `Do 17.09.`.
302
+ *
303
+ * The label cell also carries the meals/breaks icons, whose text content is
304
+ * literally `restaurant` / `local_cafe`; taking the plain text of the cell
305
+ * would render as `Do 17.09. restaurant`. Prefer the dedicated weekday and
306
+ * day/month divs and only fall back to text surgery.
307
+ */
308
+ function dayRowLabel(cellHtml: string, joined: string): string {
309
+ const weekday = /class="modimidofrsaso"[^>]*>\s*([A-Za-z]{2})\s*</.exec(cellHtml)?.[1];
310
+ const dayMonth = /class="tag_monat"[^>]*>\s*([0-9]{2}\.[0-9]{2}\.)\s*</.exec(cellHtml)?.[1];
311
+ if (weekday && dayMonth) return `${weekday} ${dayMonth}`;
312
+
313
+ return joined
314
+ .split(/\s{2,}/)[0]!
315
+ .replace(/\s*(restaurant|local_cafe)\b.*$/i, "")
316
+ .trim();
317
+ }
318
+
319
+ /**
320
+ * Place of work of a booking row, or null when ZEP does not render it.
321
+ *
322
+ * The Ort cell is the row's last column (`von` + 9) and, unlike the other
323
+ * metadata columns, it carries no `data-columnid` of its own. ZEP only emits
324
+ * it when there is something to report, so an absent cell means the booking
325
+ * sits on the default place of work (`NULL`). A trailing `*` marks a
326
+ * deviation from that default and is not part of the value.
327
+ */
328
+ function bookingOrt(flat: ReadonlyArray<string>, from: number): string | null {
329
+ return (flat[from + 9] ?? "").replace(/[\s*]+$/, "").trim() || null;
330
+ }
331
+
300
332
  /**
301
333
  * Parse the week table rendered into `#ProjektzeitTableDiv`.
302
334
  *
303
- * Row shapes observed on the live system:
304
- * day row `["Do 17.09. restaurant", ...]` (icons = meals/breaks)
305
- * booking row `["editdelete", von, bis, Dauer, "", Projekt, Vorgang, Taet, "payments", Bemerkung]`
306
- * sum row `["Summe", "8,00", "payments 8,00"]`
335
+ * Row shapes observed on the live system (2026-09-17, ZEP v7.13.88):
336
+ *
337
+ * day, no bookings `["Do 17.09. restaurant", ""]`
338
+ * day, with bookings `["Do 17.09. restaurant", "", "editdelete", von, bis, Dauer, "", Projekt, Vorgang, Taet, fakturierbar, Bemerkung, Ort]`
339
+ * further bookings `["editdelete", von, bis, Dauer, "", Projekt, Vorgang, Taet, "payments", Bemerkung, Ort]`
340
+ * sum row `["Summe", "4,00", ...]`
341
+ *
342
+ * The day's FIRST booking is rendered inside the day row itself (the label
343
+ * cell then has `rowspan=N`), which is why the booking columns must be read
344
+ * relative to the first `HH:MM` cell rather than at a fixed offset.
307
345
  */
308
346
  export function parseWeek(tableHtml: string, kwDate: string): ZepWeek {
309
347
  const days: ZepWeekDay[] = [];
@@ -341,19 +379,22 @@ export function parseWeek(tableHtml: string, kwDate: string): ZepWeek {
341
379
  // --- day header -------------------------------------------------------
342
380
  const joined = flat.join(" ").trim();
343
381
  const dayMatch = DAY_RE.exec(joined);
344
- if (dayMatch && cells.length <= 3) {
382
+ // A day row is identified by its `id="day_YYYY-MM-DD"` cell; the label
383
+ // text is only used as a fallback so that a markup change in the label
384
+ // cannot silently re-attach a day's bookings to the previous day.
385
+ const dayId =
386
+ /id="day_(\d{4}-\d{2}-\d{2})"/.exec(row.html) ??
387
+ /openMahlzeiten\s*\(\s*this\s*,\s*'(\d{4}-\d{2}-\d{2})'/.exec(row.html);
388
+ if (dayId || (dayMatch && cells.length <= 3)) {
345
389
  flush();
346
- const iso =
347
- /id="day_(\d{4}-\d{2}-\d{2})"/.exec(row.html) ??
348
- /openMahlzeiten\s*\(\s*this\s*,\s*'(\d{4}-\d{2}-\d{2})'/.exec(row.html);
349
- const label = joined.split(/\s{2,}/)[0] ?? joined;
390
+ const label = dayRowLabel(cells[0] ?? "", joined) || joined;
350
391
  current = {
351
392
  label,
352
- date: iso?.[1] ?? resolveDayDate(label, kwDate),
393
+ date: dayId?.[1] ?? resolveDayDate(label, kwDate),
353
394
  bookings: [],
354
395
  classes: row.classes,
355
396
  };
356
- continue;
397
+ // deliberately no `continue`: this row may carry the day's first booking
357
398
  }
358
399
 
359
400
  if (!current) continue;
@@ -368,22 +409,26 @@ export function parseWeek(tableHtml: string, kwDate: string): ZepWeek {
368
409
  }
369
410
 
370
411
  // --- booking row ------------------------------------------------------
371
- const from = /^\d{2}:\d{2}$/.test(flat[1] ?? "");
372
- if (!from) continue;
412
+ // Anchor on the `von` cell: cell 1 in a standalone booking row, cell 3
413
+ // behind the label in a day row that carries its first booking.
414
+ const from = flat.findIndex((c, index) => index > 0 && /^\d{2}:\d{2}$/.test(c));
415
+ if (from === -1) continue;
373
416
 
374
- const objectId = /objectId=(\d+)/.exec(row.html)?.[1] ?? null;
417
+ const objectId =
418
+ /objectId=?(\d+)/.exec(row.html)?.[1] ?? null;
375
419
  current.bookings.push({
376
420
  objectId,
377
421
  dayLabel: current.label,
378
422
  date: current.date,
379
- from: flat[1] ?? "",
380
- to: flat[2] ?? "",
381
- duration: flat[3] ?? "",
382
- project: flat[5] ?? "",
383
- vorgang: flat[6] ?? "",
384
- taetigkeit: flat[7] ?? "",
385
- billable: /payments/i.test(flat[8] ?? "") || /payments/i.test(row.html),
386
- comment: flat[9] ?? "",
423
+ from: flat[from] ?? "",
424
+ to: flat[from + 1] ?? "",
425
+ duration: flat[from + 2] ?? "",
426
+ project: flat[from + 4] ?? "",
427
+ vorgang: flat[from + 5] ?? "",
428
+ taetigkeit: flat[from + 6] ?? "",
429
+ billable: /payments/i.test(flat[from + 7] ?? "") || /payments/i.test(row.html),
430
+ comment: flat[from + 8] ?? "",
431
+ ort: bookingOrt(flat, from),
387
432
  });
388
433
  }
389
434
 
package/src/config.ts CHANGED
@@ -10,14 +10,20 @@
10
10
  * on every call.
11
11
  */
12
12
 
13
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
13
+ import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
14
14
  import { homedir } from "node:os";
15
15
  import { dirname, join } from "node:path";
16
16
 
17
17
  import type { ZepAccount, ZepConfigFile, ZepSession } from "./types.ts";
18
18
 
19
- const CONFIG_PATH = join(homedir(), ".pi", "zep-config.json");
20
- const SESSION_PATH = join(homedir(), ".pi", "zep-session.json");
19
+ /** Resolved on every call, so honouring $HOME works and tests can relocate it. */
20
+ function configFile(): string {
21
+ return join(homedir(), ".pi", "zep-config.json");
22
+ }
23
+
24
+ function sessionFile(): string {
25
+ return join(homedir(), ".pi", "zep-session.json");
26
+ }
21
27
 
22
28
  export const DEFAULT_BASE_URL = "https://zep-online.de";
23
29
  export const DEFAULT_MANDANT = "zepneoimpulse";
@@ -28,6 +34,19 @@ let config: { profiles: Record<string, ZepAccount>; activeProfile: string | null
28
34
  };
29
35
  let sessions: Record<string, ZepSession> = {};
30
36
 
37
+ // mtimes of the last read, so refreshConfig() can tell whether the files
38
+ // changed on disk. -1 means "never read".
39
+ let configMtime = -1;
40
+ let sessionMtime = -1;
41
+
42
+ function mtimeOf(path: string): number {
43
+ try {
44
+ return statSync(path).mtimeMs;
45
+ } catch {
46
+ return -1;
47
+ }
48
+ }
49
+
31
50
  function writePrivate(path: string, data: unknown): void {
32
51
  const dir = dirname(path);
33
52
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
@@ -49,12 +68,31 @@ function readJson<T>(path: string, fallback: T): T {
49
68
  }
50
69
 
51
70
  export function loadConfig(): void {
52
- const loaded = readJson<ZepConfigFile>(CONFIG_PATH, { profiles: {}, activeProfile: null });
71
+ const loaded = readJson<ZepConfigFile>(configFile(), { profiles: {}, activeProfile: null });
53
72
  config = {
54
73
  profiles: loaded?.profiles ?? {},
55
74
  activeProfile: loaded?.activeProfile ?? null,
56
75
  };
57
- sessions = readJson<Record<string, ZepSession>>(SESSION_PATH, {});
76
+ sessions = readJson<Record<string, ZepSession>>(sessionFile(), {});
77
+ configMtime = mtimeOf(configFile());
78
+ sessionMtime = mtimeOf(sessionFile());
79
+ }
80
+
81
+ /**
82
+ * Re-read the files when they changed on disk.
83
+ *
84
+ * pi loads an extension once per session, so without this a config file that
85
+ * the user wrote by hand (the documented alternative to zep_setup) is only
86
+ * picked up after a restart - the tools would keep reporting
87
+ * "No ZEP profile configured" while a perfectly valid file sits on disk.
88
+ *
89
+ * Cheap: two stat() calls in the common case where nothing changed.
90
+ */
91
+ export function refreshConfig(): void {
92
+ const nextConfig = mtimeOf(configFile());
93
+ const nextSession = mtimeOf(sessionFile());
94
+ if (nextConfig === configMtime && nextSession === sessionMtime) return;
95
+ loadConfig();
58
96
  }
59
97
 
60
98
  export function getConfig(): ZepConfigFile {
@@ -102,14 +140,14 @@ export function getProfile(name?: string): ZepAccount {
102
140
  export function saveProfile(name: string, account: ZepAccount): void {
103
141
  config.profiles[name] = account;
104
142
  if (!config.activeProfile) config.activeProfile = name;
105
- writePrivate(CONFIG_PATH, config);
143
+ writePrivate(configFile(), config);
106
144
  }
107
145
 
108
146
  export function updateProfile(name: string, patch: Partial<ZepAccount>): void {
109
147
  const existing = config.profiles[name];
110
148
  if (!existing) return;
111
149
  config.profiles[name] = { ...existing, ...patch };
112
- writePrivate(CONFIG_PATH, config);
150
+ writePrivate(configFile(), config);
113
151
  }
114
152
 
115
153
  export function useProfile(name: string): void {
@@ -117,7 +155,7 @@ export function useProfile(name: string): void {
117
155
  throw new Error(`ZEP profile "${name}" not found.`);
118
156
  }
119
157
  config.activeProfile = name;
120
- writePrivate(CONFIG_PATH, config);
158
+ writePrivate(configFile(), config);
121
159
  }
122
160
 
123
161
  export function deleteProfile(name: string): void {
@@ -126,16 +164,16 @@ export function deleteProfile(name: string): void {
126
164
  if (config.activeProfile === name) {
127
165
  config.activeProfile = listProfiles()[0] ?? null;
128
166
  }
129
- writePrivate(CONFIG_PATH, config);
130
- writePrivate(SESSION_PATH, sessions);
167
+ writePrivate(configFile(), config);
168
+ writePrivate(sessionFile(), sessions);
131
169
  }
132
170
 
133
171
  export function configPath(): string {
134
- return CONFIG_PATH;
172
+ return configFile();
135
173
  }
136
174
 
137
175
  export function sessionPath(): string {
138
- return SESSION_PATH;
176
+ return sessionFile();
139
177
  }
140
178
 
141
179
  // ------------------------------------------------------------------- sessions
@@ -146,10 +184,10 @@ export function getSession(profileName: string): ZepSession | undefined {
146
184
 
147
185
  export function saveSession(profileName: string, session: ZepSession): void {
148
186
  sessions[profileName] = session;
149
- writePrivate(SESSION_PATH, sessions);
187
+ writePrivate(sessionFile(), sessions);
150
188
  }
151
189
 
152
190
  export function clearSession(profileName: string): void {
153
191
  delete sessions[profileName];
154
- writePrivate(SESSION_PATH, sessions);
192
+ writePrivate(sessionFile(), sessions);
155
193
  }
@@ -65,7 +65,8 @@ export function formatWeek(week: ZepWeek): string {
65
65
  lines.push(
66
66
  ` #${booking.objectId ?? "?"} ${booking.from}-${booking.to} (${booking.duration}) ` +
67
67
  `${booking.project} / ${booking.vorgang} / ${booking.taetigkeit}` +
68
- `${booking.billable ? " [billable]" : ""}`,
68
+ `${booking.billable ? " [billable]" : ""}` +
69
+ `${booking.ort ? ` @ ${booking.ort}` : ""}`,
69
70
  );
70
71
  if (booking.comment) lines.push(` ${booking.comment}`);
71
72
  }
package/src/session.ts CHANGED
@@ -6,7 +6,14 @@
6
6
  * after 5 failed password attempts.
7
7
  */
8
8
 
9
- import { clearSession, getActiveProfileName, getProfile, getSession, saveSession } from "./config.ts";
9
+ import {
10
+ clearSession,
11
+ getActiveProfileName,
12
+ getProfile,
13
+ getSession,
14
+ refreshConfig,
15
+ saveSession,
16
+ } from "./config.ts";
10
17
  import { ZepClient, ZepError } from "./clients/zep-client.ts";
11
18
 
12
19
  export interface ClientHandle {
@@ -17,6 +24,7 @@ export interface ClientHandle {
17
24
  }
18
25
 
19
26
  export async function createClient(profileName?: string): Promise<ClientHandle> {
27
+ refreshConfig();
20
28
  const name = profileName ?? getActiveProfileName();
21
29
  if (!name) {
22
30
  throw new ZepError("No ZEP profile configured. Call zep_setup first.");
@@ -62,6 +70,7 @@ export async function createClient(profileName?: string): Promise<ClientHandle>
62
70
  * Used by zep_login force=true.
63
71
  */
64
72
  export async function loginProfile(profileName: string): Promise<ZepClient> {
73
+ refreshConfig();
65
74
  const account = getProfile(profileName);
66
75
  clearSession(profileName);
67
76
 
@@ -64,13 +64,13 @@ export const ZepBookTool = {
64
64
  bemerkung: Type.Optional(
65
65
  Type.String({
66
66
  description:
67
- "Comment. Convention: include the ticket/story numbers (e.g. '3447 Ladeanzeige nach Pick, 3455 Fach-HU-Nummer').",
67
+ "Comment. Convention: include the ticket/story numbers (e.g. '4711 Ladeanzeige, 4712 Fach-HU-Nummer').",
68
68
  }),
69
69
  ),
70
70
  projekt: Type.Optional(
71
71
  Type.String({
72
72
  description:
73
- "Project id (e.g. '413' for Julabo) or part of its label (e.g. 'Julabo'). Falls back to the project ZEP has selected for this user.",
73
+ "Project id (e.g. '413' for Contoso) or part of its label (e.g. 'Contoso'). Falls back to the project ZEP has selected for this user.",
74
74
  }),
75
75
  ),
76
76
  vorgang: Type.Optional(
@@ -85,7 +85,10 @@ export const ZepBookTool = {
85
85
  }),
86
86
  ),
87
87
  ort: Type.Optional(
88
- Type.String({ description: "Location code, e.g. 'D-Office' or 'D'. Defaults to 'NULL' (first place of work)." }),
88
+ Type.String({
89
+ description:
90
+ "Location code, e.g. 'D-Office' (office), 'D' (inland away) or 'D-3M'. The default 'NULL' is the first place of work (Erste Tätigkeitsstätte).",
91
+ }),
89
92
  ),
90
93
  entries: Type.Optional(
91
94
  Type.Array(
@@ -228,6 +231,10 @@ export const ZepBookTool = {
228
231
  const comment = entry.bemerkung ?? params.bemerkung ?? "";
229
232
 
230
233
  const vorgangLabel = scope.vorgaenge.find((v) => v.id === vorgangId)?.label;
234
+ // Always state the place of work that goes onto the wire: 'NULL' is
235
+ // the default (Erste Tätigkeitsstätte) and is exactly what a booking
236
+ // without an explicit ort gets, so it must not be invisible here.
237
+ const ortLabel = form.orte.find((o) => o.id === ort)?.label;
231
238
 
232
239
  planned.push({
233
240
  request: {
@@ -245,7 +252,8 @@ export const ZepBookTool = {
245
252
  `${date} ${from}-${to} (${duration})\n` +
246
253
  ` project ${projectId}${scope.project ? ` "${scope.project.label}"` : ""}\n` +
247
254
  ` Vorgang ${vorgangId}${vorgangLabel ? ` "${vorgangLabel}"` : ""}\n` +
248
- ` Tätigkeit ${taetigkeit}${ort === "NULL" ? "" : `, Ort ${ort}`}` +
255
+ ` Tätigkeit ${taetigkeit}, Ort ${ort}` +
256
+ `${ortLabel && ortLabel !== ort ? ` (${ortLabel})` : ""}` +
249
257
  `${comment ? `\n "${comment}"` : ""}`,
250
258
  });
251
259
  }
@@ -0,0 +1,297 @@
1
+ /**
2
+ * zep_doctor -- diagnose the ZEP setup without spending a login attempt.
3
+ *
4
+ * Why this exists: every login attempt costs one of only five tries before
5
+ * ZEP locks the account, and a rejected login says only "Benutzername oder
6
+ * Kennwort falsch" - it cannot distinguish a wrong userid from a wrong
7
+ * password, nor from a config file that carries a trailing space. This tool
8
+ * checks everything that can be checked for free, and performs the real
9
+ * credential check only when explicitly asked for.
10
+ */
11
+
12
+ import { existsSync, statSync } from "node:fs";
13
+ import { Type } from "typebox";
14
+
15
+ import {
16
+ configPath,
17
+ getActiveProfileName,
18
+ getProfile,
19
+ getSession,
20
+ listProfiles,
21
+ refreshConfig,
22
+ sessionPath,
23
+ } from "../config.ts";
24
+ import { ZepClient } from "../clients/zep-client.ts";
25
+ import { errorResult, textResult, type ToolResult } from "./shared.ts";
26
+
27
+ interface Check {
28
+ readonly name: string;
29
+ readonly status: "ok" | "warn" | "fail" | "skip";
30
+ readonly detail: string;
31
+ /** What to do about it, when it is not "ok". */
32
+ readonly hint?: string;
33
+ }
34
+
35
+ const ICON = { ok: "OK ", warn: "WARN", fail: "FAIL", skip: "SKIP" } as const;
36
+
37
+ function render(checks: ReadonlyArray<Check>): string {
38
+ return checks
39
+ .map((c) => {
40
+ const head = `${ICON[c.status]} ${c.name}: ${c.detail}`;
41
+ return c.hint ? `${head}\n -> ${c.hint}` : head;
42
+ })
43
+ .join("\n");
44
+ }
45
+
46
+ export const ZepDoctorTool = {
47
+ name: "zep_doctor",
48
+ label: "ZEP Doctor",
49
+ description:
50
+ "Check the ZEP configuration and connectivity before booking: config file, permissions, profile fields, password hygiene (whitespace is the usual cause of 'Benutzername oder Kennwort falsch'), cached session, and reachability of the server. Uses no login attempt unless checkCredentials is set, and reports how many password tries are left.",
51
+ parameters: Type.Object({
52
+ profile: Type.Optional(
53
+ Type.String({ description: "ZEP profile to check. Defaults to the active profile." }),
54
+ ),
55
+ checkCredentials: Type.Optional(
56
+ Type.Boolean({
57
+ description:
58
+ "Also perform ONE real login attempt to verify userid and password. This consumes one of the 5 tries before ZEP locks the account, so it is off by default.",
59
+ }),
60
+ ),
61
+ }),
62
+
63
+ async execute(
64
+ _toolCallId: string,
65
+ params: { profile?: string; checkCredentials?: boolean },
66
+ ): Promise<ToolResult> {
67
+ // Re-read from disk: the doctor is exactly the tool you run right after
68
+ // editing the config by hand.
69
+ refreshConfig();
70
+ const checks: Check[] = [];
71
+
72
+ // ------------------------------------------------------------ config file
73
+ const path = configPath();
74
+ if (!existsSync(path)) {
75
+ checks.push({
76
+ name: "config file",
77
+ status: "fail",
78
+ detail: `${path} does not exist`,
79
+ hint: "Run zep_setup, or write the file by hand (profiles.<name>.userid/password, activeProfile).",
80
+ });
81
+ return errorResult(render(checks), { ok: false });
82
+ }
83
+
84
+ const mode = statSync(path).mode & 0o777;
85
+ checks.push({
86
+ name: "config file",
87
+ status: mode === 0o600 ? "ok" : "warn",
88
+ detail: `${path} (mode ${mode.toString(8)})`,
89
+ ...(mode === 0o600
90
+ ? {}
91
+ : { hint: `chmod 600 ${path} - the file holds a password in clear text.` }),
92
+ });
93
+
94
+ const profiles = listProfiles();
95
+ const name = params.profile ?? getActiveProfileName();
96
+ if (!name) {
97
+ checks.push({
98
+ name: "active profile",
99
+ status: "fail",
100
+ detail: `none set (known: ${profiles.join(", ") || "none"})`,
101
+ hint: 'Set "activeProfile" in the config file, or run zep_profile action=use.',
102
+ });
103
+ return errorResult(render(checks), { ok: false });
104
+ }
105
+
106
+ let account;
107
+ try {
108
+ account = getProfile(name);
109
+ } catch (error) {
110
+ checks.push({
111
+ name: `profile "${name}"`,
112
+ status: "fail",
113
+ detail: error instanceof Error ? error.message : String(error),
114
+ hint: `Known profiles: ${profiles.join(", ") || "none"}`,
115
+ });
116
+ return errorResult(render(checks), { ok: false });
117
+ }
118
+ checks.push({ name: "profile", status: "ok", detail: `"${name}" (active)` });
119
+
120
+ // --------------------------------------------------------------- fields
121
+ const missing = (["baseUrl", "mandant", "userid", "password"] as const).filter(
122
+ (field) => !account[field],
123
+ );
124
+ checks.push(
125
+ missing.length === 0
126
+ ? { name: "profile fields", status: "ok", detail: "baseUrl, mandant, userid, password" }
127
+ : {
128
+ name: "profile fields",
129
+ status: "fail",
130
+ detail: `missing or empty: ${missing.join(", ")}`,
131
+ hint: 'Add the field to the profile in the config file, or re-run zep_setup.',
132
+ },
133
+ );
134
+
135
+ checks.push({
136
+ name: "target",
137
+ status: "ok",
138
+ detail: `${account.baseUrl}/${account.mandant} as ${account.userid}`,
139
+ });
140
+
141
+ // ------------------------------------------------- password hygiene
142
+ const raw = account.password ?? "";
143
+ const hygiene = passwordShapeIssues(raw);
144
+
145
+ checks.push(
146
+ hygiene.length === 0
147
+ ? { name: "password shape", status: "ok", detail: `${raw.length} characters, no whitespace` }
148
+ : {
149
+ name: "password shape",
150
+ status: "fail",
151
+ detail: hygiene.join("; "),
152
+ hint: "Fix the stored password. ZEP reports all of these as 'Benutzername oder Kennwort falsch'.",
153
+ },
154
+ );
155
+
156
+ // --------------------------------------------------------- session cache
157
+ const cached = getSession(name);
158
+ let liveSession: ZepClient | null = null;
159
+
160
+ if (!cached) {
161
+ checks.push({
162
+ name: "cached session",
163
+ status: "skip",
164
+ detail: `${sessionPath()} has no entry for "${name}"`,
165
+ hint: "A login is needed once; after that the session is reused.",
166
+ });
167
+ } else {
168
+ const client = new ZepClient(account, {});
169
+ client.applySession(cached);
170
+ const alive = await client.probe();
171
+ checks.push({
172
+ name: "cached session",
173
+ status: alive ? "ok" : "warn",
174
+ detail: `CLIENTSESSID ${cached.clientsessid} saved ${cached.savedAt} - ${
175
+ alive ? "still accepted" : "no longer accepted"
176
+ }`,
177
+ ...(alive ? {} : { hint: "It expired; the next call performs a fresh login." }),
178
+ });
179
+ if (alive) liveSession = client;
180
+ }
181
+
182
+ // ------------------------------------------------------------ reachability
183
+ // A dedicated client: ping() primes a fresh PHPSESSID, which must not
184
+ // overwrite the one belonging to a live session.
185
+ try {
186
+ const ping = await new ZepClient(account, {}).ping();
187
+ checks.push({
188
+ name: "reachability",
189
+ status: ping.loginPageOk || ping.redirectedToApp ? "ok" : "warn",
190
+ detail: `GET /view/login.php -> HTTP ${ping.status}, login form ${
191
+ ping.loginPageOk ? "present" : "absent"
192
+ }${ping.redirectedToApp ? ", redirects into the app (already authenticated)" : ""}`,
193
+ ...(ping.loginPageOk || ping.redirectedToApp
194
+ ? {}
195
+ : { hint: "Unexpected response; a proxy or maintenance page may be in the way." }),
196
+ });
197
+ } catch (error) {
198
+ checks.push({
199
+ name: "reachability",
200
+ status: "fail",
201
+ detail: error instanceof Error ? error.message : String(error),
202
+ hint: "Check network/proxy. Override baseUrl per profile if a mirror is used.",
203
+ });
204
+ }
205
+
206
+ // --------------------------------------------------------- live session
207
+ if (liveSession) {
208
+ try {
209
+ const { form } = await liveSession.loadForm();
210
+ checks.push({
211
+ name: "booking form",
212
+ status: "ok",
213
+ detail: `${form.projects.length} projects, ${form.vorgaenge.length} Vorgänge for the selected project`,
214
+ });
215
+ } catch (error) {
216
+ checks.push({
217
+ name: "booking form",
218
+ status: "fail",
219
+ detail: error instanceof Error ? error.message : String(error),
220
+ });
221
+ }
222
+ }
223
+
224
+ // ------------------------------------------------------ credential check
225
+ if (params.checkCredentials) {
226
+ const client = new ZepClient(account, {});
227
+ try {
228
+ const result = await client.login();
229
+ checks.push({
230
+ name: "credential check",
231
+ status: result.ok ? "ok" : "fail",
232
+ detail: result.ok
233
+ ? `login accepted (CLIENTSESSID ${result.clientsessid})`
234
+ : `${result.message ?? "rejected"}${
235
+ result.attemptsLeft !== undefined ? ` - ${result.attemptsLeft} tries left` : ""
236
+ }`,
237
+ ...(result.ok
238
+ ? {}
239
+ : {
240
+ hint:
241
+ "ZEP cannot tell 'wrong userid' from 'wrong password'. Verify both; do NOT retry blindly - the account locks after 5.",
242
+ }),
243
+ });
244
+ } catch (error) {
245
+ checks.push({
246
+ name: "credential check",
247
+ status: "fail",
248
+ detail: error instanceof Error ? error.message : String(error),
249
+ });
250
+ }
251
+ } else {
252
+ checks.push({
253
+ name: "credential check",
254
+ status: "skip",
255
+ detail: "not attempted (costs one of 5 tries before lockout)",
256
+ hint: "Re-run with checkCredentials=true only when the stored userid/password are confirmed.",
257
+ });
258
+ }
259
+
260
+ const text = render(checks);
261
+ const failed = checks.some((c) => c.status === "fail");
262
+ const warnOrFail = failed || checks.some((c) => c.status === "warn");
263
+
264
+ const verdict = failed
265
+ ? "\n\nVerdict: something must be fixed before booking will work."
266
+ : warnOrFail
267
+ ? "\n\nVerdict: usable, with warnings above."
268
+ : "\n\nVerdict: everything checks out.";
269
+
270
+ return warnOrFail
271
+ ? errorResult(text + verdict, { ok: !failed, checks })
272
+ : textResult(text + verdict, { ok: true, checks });
273
+ },
274
+ };
275
+
276
+ /**
277
+ * Problems with a stored password that ZEP would all report as
278
+ * "Benutzername oder Kennwort falsch" - i.e. silently. Pure, so it is testable
279
+ * without a config file.
280
+ */
281
+ export function passwordShapeIssues(raw: string): string[] {
282
+ const issues: string[] = [];
283
+ if (raw !== raw.trim()) issues.push("leading/trailing whitespace (copy-paste!)");
284
+ if (/\s/.test(raw.trim())) issues.push("contains an inner whitespace character");
285
+ if (raw === "undefined" || raw === "null") issues.push(`literally the string "${raw}"`);
286
+ if (raw.length < 6) issues.push(`suspiciously short (${raw.length} chars)`);
287
+ // `!` starts history expansion in an interactive bash. A password written
288
+ // through a shell command can silently lose everything from the `!` on -
289
+ // and ZEP then only says "wrong password".
290
+ if (raw.includes("!")) {
291
+ issues.push(
292
+ `contains "!" (${raw.length} chars) - in an interactive shell "!" starts history expansion, ` +
293
+ "so a password written via a command line may be truncated at that position",
294
+ );
295
+ }
296
+ return issues;
297
+ }
@@ -10,6 +10,7 @@ import {
10
10
  getActiveProfileName,
11
11
  listProfiles,
12
12
  useProfile,
13
+ refreshConfig,
13
14
  } from "../config.ts";
14
15
  import { errorResult, textResult, type ToolResult } from "./shared.ts";
15
16
 
@@ -31,6 +32,7 @@ export const ZepProfileTool = {
31
32
  _toolCallId: string,
32
33
  params: { action?: string; name?: string },
33
34
  ): Promise<ToolResult> {
35
+ refreshConfig();
34
36
  const action = params.action ?? "list";
35
37
  try {
36
38
  if (action === "list") {
@@ -4,7 +4,13 @@
4
4
 
5
5
  import { Type } from "typebox";
6
6
 
7
- import { configPath, getActiveProfileName, listProfiles, sessionPath } from "../config.ts";
7
+ import {
8
+ configPath,
9
+ getActiveProfileName,
10
+ listProfiles,
11
+ refreshConfig,
12
+ sessionPath,
13
+ } from "../config.ts";
8
14
  import { ZepClient } from "../clients/zep-client.ts";
9
15
  import { createClient, ZepError } from "../session.ts";
10
16
  import { formatFormData } from "../formatting/formatters.ts";
@@ -35,6 +41,7 @@ export const ZepStatusTool = {
35
41
  _toolCallId: string,
36
42
  params: { profile?: string; probe?: boolean; relogin?: boolean },
37
43
  ): Promise<ToolResult> {
44
+ refreshConfig();
38
45
  const lines: string[] = [];
39
46
  const profiles = listProfiles();
40
47
 
package/src/types.ts CHANGED
@@ -94,6 +94,13 @@ export interface ZepBooking {
94
94
  readonly taetigkeit: string;
95
95
  readonly billable: boolean;
96
96
  readonly comment: string;
97
+ /**
98
+ * Place of work as the table renders it, e.g. `D-Office`.
99
+ *
100
+ * ZEP only draws this cell when there is something to say, so `null` means
101
+ * "no cell rendered" and therefore the default place of work (`NULL`).
102
+ */
103
+ readonly ort: string | null;
97
104
  }
98
105
 
99
106
  export interface ZepWeekDay {