@tplog/pi-zendy 0.3.8 → 0.4.0

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
@@ -75,18 +75,6 @@ The agent can call these tools directly:
75
75
  | `zendy_kg_search` | Semantic search over historical tickets |
76
76
  | `zendy_source_status` | Check source analysis workspace |
77
77
 
78
- ## Legacy Launcher
79
-
80
- For users of the old `zendy` CLI:
81
-
82
- ```bash
83
- npm install -g @tplog/pi-zendy
84
- zendy
85
- ```
86
-
87
- The legacy launcher starts pi with the zendy extension and system prompt. It still supports
88
- `zendy preflight` and `zendy cleanup-src` as standalone subcommands.
89
-
90
78
  ## How it works
91
79
 
92
80
  zendy registers as a pi extension package. The extension provides tools (callable by the LLM),
@@ -7,10 +7,43 @@ import * as zendesk from "../dist/clients/zendesk.js";
7
7
  import * as helm from "../dist/clients/helm-watchdog.js";
8
8
  import * as kg from "../dist/clients/zendesk-kg.js";
9
9
 
10
+ // IMPORTANT: only `content` is sent to the model; `details` is UI/extension
11
+ // metadata the model never sees (verified empirically against pi 0.79). Any
12
+ // data the model must reason about has to be serialized into the text.
10
13
  function textResult(text: string, details: Record<string, unknown> = {}) {
11
14
  return { content: [{ type: "text" as const, text }], details };
12
15
  }
13
16
 
17
+ function fmtComment(c: zendesk.SlimComment, users: Map<number, string>): string {
18
+ const who = users.get(c.author_id) ?? `user:${c.author_id}`;
19
+ const vis = c.public ? "public" : "internal";
20
+ return `--- comment by ${who} (${vis}, ${c.created_at}) ---\n${c.plain_body ?? c.body ?? ""}`;
21
+ }
22
+
23
+ function fmtTicket(r: zendesk.TicketResult): string {
24
+ const t = r.ticket;
25
+ const users = new Map<number, string>();
26
+ if (r.requester) users.set(r.requester.id, `${r.requester.name} <${r.requester.email}> (requester)`);
27
+ if (r.assignee) users.set(r.assignee.id, `${r.assignee.name} <${r.assignee.email}> (assignee)`);
28
+ const customFields = (t.custom_fields ?? []).filter((f) => f.value !== null && f.value !== "");
29
+ const lines = [
30
+ `# Ticket #${t.id}: ${t.subject}`,
31
+ `status: ${t.status} | priority: ${t.priority ?? "-"} | created: ${t.created_at} | updated: ${t.updated_at}`,
32
+ `requester: ${r.requester ? `${r.requester.name} <${r.requester.email}>` : t.requester_id}`,
33
+ `assignee: ${r.assignee ? `${r.assignee.name} <${r.assignee.email}>` : t.assignee_id ?? "-"}`,
34
+ `tags: ${(t.tags ?? []).join(", ") || "-"}`,
35
+ customFields.length ? `custom_fields: ${JSON.stringify(customFields)}` : "",
36
+ "",
37
+ `## Description`,
38
+ t.description ?? "",
39
+ ].filter(Boolean);
40
+ if (r.comments?.length) {
41
+ lines.push("", `## Comments (${r.comments.length})`);
42
+ for (const c of r.comments) lines.push(fmtComment(c, users));
43
+ }
44
+ return lines.join("\n");
45
+ }
46
+
14
47
  function registerZendeskTools(pi: ExtensionAPI): void {
15
48
  pi.registerTool({
16
49
  name: "zendy_ticket_get",
@@ -26,15 +59,12 @@ function registerZendeskTools(pi: ExtensionAPI): void {
26
59
  }),
27
60
  async execute(_toolCallId: string, params: { ticketId: number }, signal?: AbortSignal) {
28
61
  const result = await zendesk.getTicketFull(params.ticketId, signal);
29
- return textResult(
30
- `Fetched Zendesk ticket #${result.ticket.id}: "${result.ticket.subject}" (${result.ticket.status}), ${result.comments?.length ?? 0} comments.`,
31
- {
32
- ticket: result.ticket,
33
- comments: result.comments,
34
- requester: result.requester,
35
- assignee: result.assignee,
36
- },
37
- );
62
+ return textResult(fmtTicket(result), {
63
+ ticket: result.ticket,
64
+ comments: result.comments,
65
+ requester: result.requester,
66
+ assignee: result.assignee,
67
+ });
38
68
  },
39
69
  });
40
70
 
@@ -113,31 +143,52 @@ function registerHelmTools(pi: ExtensionAPI): void {
113
143
  switch (params.resource) {
114
144
  case "version": {
115
145
  const data = await helm.getVersion(params.version!, signal);
116
- return textResult(`Helm Watchdog version metadata for ${params.version}.`, { version: params.version, data });
146
+ return textResult(
147
+ `Helm Watchdog version metadata for ${params.version}:\n${JSON.stringify(data, null, 2)}`,
148
+ { version: params.version, data },
149
+ );
117
150
  }
118
151
  case "values": {
119
152
  const data = await helm.getValues(params.version!, signal);
120
- return textResult(`Helm Watchdog values.yaml for ${params.version}.`, { version: params.version, data });
153
+ return textResult(
154
+ `values.yaml for chart ${params.version}:\n\n${data}`,
155
+ { version: params.version, data },
156
+ );
121
157
  }
122
158
  case "images": {
123
159
  const data = await helm.getImages(params.version!, params.validateImages, signal);
124
- return textResult(`Helm Watchdog images for ${params.version} (${data.length} images).`, { version: params.version, images: data });
160
+ return textResult(
161
+ `Images for chart ${params.version} (${data.length}):\n${JSON.stringify(data, null, 2)}`,
162
+ { version: params.version, images: data },
163
+ );
125
164
  }
126
165
  case "validation": {
127
166
  const data = await helm.getValidation(params.version!, params.status, signal);
128
- return textResult(`Helm Watchdog validation for ${params.version}.`, { version: params.version, results: data });
167
+ return textResult(
168
+ `Validation for chart ${params.version}:\n${JSON.stringify(data, null, 2)}`,
169
+ { version: params.version, results: data },
170
+ );
129
171
  }
130
172
  case "latest": {
131
173
  const data = await helm.getLatest(params.versionOnly, signal);
132
- return textResult("Helm Watchdog latest version.", { data });
174
+ return textResult(
175
+ `Latest chart version: ${typeof data === "string" ? data : JSON.stringify(data, null, 2)}`,
176
+ { data },
177
+ );
133
178
  }
134
179
  case "versions": {
135
180
  const data = await helm.listVersions(signal);
136
- return textResult(`Helm Watchdog cached versions (${data.length} entries).`, { versions: data });
181
+ return textResult(
182
+ `Cached chart versions (${data.length}):\n${JSON.stringify(data, null, 2)}`,
183
+ { versions: data },
184
+ );
137
185
  }
138
186
  case "cache": {
139
187
  const data = await helm.getCache(signal);
140
- return textResult("Helm Watchdog cache metadata.", { data });
188
+ return textResult(
189
+ `Cache metadata:\n${JSON.stringify(data, null, 2)}`,
190
+ { data },
191
+ );
141
192
  }
142
193
  }
143
194
  },
@@ -178,8 +229,15 @@ function registerKnowledgeGraphTools(pi: ExtensionAPI): void {
178
229
  ...(params.status ? { status: params.status } : {}),
179
230
  },
180
231
  }, signal);
232
+ const blocks = result.results.map((r, i) => [
233
+ `[${i + 1}] ticketId: ${r.ticketId} — ${r.subject}`,
234
+ ` status: ${r.status} | priority: ${r.priority} | created: ${r.createdAt} | versions: ${(r.versions ?? []).join(", ") || "-"} | rrfScore: ${r.rrfScore}`,
235
+ r.quickSummary ? ` quick: ${r.quickSummary}` : "",
236
+ r.issueSummary ? ` issue: ${r.issueSummary}` : "",
237
+ r.solutionSummary ? ` solution: ${r.solutionSummary}` : "",
238
+ ].filter(Boolean).join("\n"));
181
239
  return textResult(
182
- `KG search returned ${result.results.length} results for "${result.queryText}". Cite ticketId values from details.results.`,
240
+ `KG search returned ${result.results.length} results for "${result.queryText}". Cite ticketId values when summarizing.\n\n${blocks.join("\n\n")}`,
183
241
  { results: result.results, queryText: result.queryText },
184
242
  );
185
243
  },
@@ -194,10 +252,12 @@ function registerSourceTools(pi: ExtensionAPI): void {
194
252
  promptSnippet: "Check source workspace status with zendy_source_status.",
195
253
  parameters: Type.Object({}),
196
254
  async execute() {
197
- return textResult("Source workspace status.", {
198
- workspace: process.env["ZENDY_SRC_DIR"] ?? null,
199
- note: "Source cloning/search requires explicit user permission per zendy workflow rules. Ask before cloning private source.",
200
- });
255
+ const workspace = process.env["ZENDY_SRC_DIR"] ?? null;
256
+ const note = "Source cloning/search requires explicit user permission per zendy workflow rules. Ask before cloning private source.";
257
+ return textResult(
258
+ `Source workspace: ${workspace ?? "(not active — created on session start)"}\n${note}`,
259
+ { workspace, note },
260
+ );
201
261
  },
202
262
  });
203
263
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tplog/pi-zendy",
3
- "version": "0.3.8",
3
+ "version": "0.4.0",
4
4
  "description": "Pi package for Dify Enterprise support ticket analysis",
5
5
  "repository": {
6
6
  "type": "git",
@@ -8,9 +8,6 @@
8
8
  },
9
9
  "license": "MIT",
10
10
  "type": "module",
11
- "bin": {
12
- "zendy": "dist/index.js"
13
- },
14
11
  "keywords": [
15
12
  "pi-package",
16
13
  "pi",
@@ -1 +0,0 @@
1
- export declare function runCleanupCLI(argv: string[]): Promise<number>;
@@ -1,217 +0,0 @@
1
- // CLI entry for `zendy cleanup-src`.
2
- // Invoked from src/index.ts when the first user arg is "cleanup-src".
3
- import { createInterface } from "node:readline";
4
- import { scanCandidates, classifyCandidates, deleteCandidates, findOrphanSessionDirs, formatBytes, formatAge, } from "./source-cleanup.js";
5
- const USAGE = `
6
- Usage: zendy cleanup-src [options]
7
-
8
- Wipe Dify source clones and orphan zendy session dirs from /tmp.
9
-
10
- Defaults are aggressive by design: dify-enterprise is a private repo, and
11
- every extra minute of on-disk residency is an extra minute of exposure.
12
-
13
- Options:
14
- --dry-run Show what would be deleted; delete nothing.
15
- --yes, -y Skip the confirmation prompt in interactive mode.
16
- --days N Only delete dirs older than N days.
17
- (Without this flag, all matching dirs are deleted.)
18
- --all Delete all matching dirs regardless of age (default).
19
- --keep-last N Keep the N most-recent matching dirs. Default: 0.
20
- --min-age-minutes N Never delete dirs modified within last N minutes.
21
- Default: 5 (protects an actively-running clone).
22
- --pattern P1,P2 Comma-separated prefix patterns (end with *).
23
- Default: "dify-*,zendy-session-*".
24
- --dir PATH Base directory to scan. Default: /tmp.
25
- --no-sweep-orphans Skip the orphan-session sweep (orphan = session dir
26
- whose pid is no longer alive).
27
- -h, --help Show this help.
28
-
29
- Examples:
30
- # Preview what would be deleted right now:
31
- zendy cleanup-src --dry-run
32
-
33
- # Cron/launchd safe invocation (non-interactive, only older than 1 day):
34
- zendy cleanup-src --days 1 --yes
35
- `;
36
- function parseArgs(argv) {
37
- const opts = {
38
- dryRun: false,
39
- yes: false,
40
- all: true,
41
- keepLast: 0,
42
- minAgeMinutes: 5,
43
- patterns: ["dify-*", "zendy-session-*"],
44
- dir: "/tmp",
45
- sweepOrphans: true,
46
- };
47
- for (let i = 0; i < argv.length; i++) {
48
- const a = argv[i];
49
- switch (a) {
50
- case "-h":
51
- case "--help":
52
- return { kind: "help" };
53
- case "--dry-run":
54
- opts.dryRun = true;
55
- break;
56
- case "-y":
57
- case "--yes":
58
- opts.yes = true;
59
- break;
60
- case "--all":
61
- opts.all = true;
62
- opts.days = undefined;
63
- break;
64
- case "--no-sweep-orphans":
65
- opts.sweepOrphans = false;
66
- break;
67
- case "--days": {
68
- const v = argv[++i];
69
- const n = v !== undefined ? parseInt(v, 10) : NaN;
70
- if (Number.isNaN(n) || n < 0)
71
- return { kind: "error", message: `Invalid --days value: ${v}` };
72
- opts.days = n;
73
- opts.all = false;
74
- break;
75
- }
76
- case "--keep-last": {
77
- const v = argv[++i];
78
- const n = v !== undefined ? parseInt(v, 10) : NaN;
79
- if (Number.isNaN(n) || n < 0)
80
- return { kind: "error", message: `Invalid --keep-last value: ${v}` };
81
- opts.keepLast = n;
82
- break;
83
- }
84
- case "--min-age-minutes": {
85
- const v = argv[++i];
86
- const n = v !== undefined ? parseInt(v, 10) : NaN;
87
- if (Number.isNaN(n) || n < 0)
88
- return { kind: "error", message: `Invalid --min-age-minutes value: ${v}` };
89
- opts.minAgeMinutes = n;
90
- break;
91
- }
92
- case "--pattern": {
93
- const v = argv[++i];
94
- if (!v)
95
- return { kind: "error", message: "--pattern requires a value" };
96
- opts.patterns = v.split(",").map((s) => s.trim()).filter(Boolean);
97
- if (opts.patterns.length === 0)
98
- return { kind: "error", message: "--pattern list cannot be empty" };
99
- break;
100
- }
101
- case "--dir": {
102
- const v = argv[++i];
103
- if (!v)
104
- return { kind: "error", message: "--dir requires a path" };
105
- opts.dir = v;
106
- break;
107
- }
108
- default:
109
- return { kind: "error", message: `Unknown option: ${a}` };
110
- }
111
- }
112
- return { kind: "ok", opts };
113
- }
114
- function promptYesNo(question) {
115
- return new Promise((resolve) => {
116
- const rl = createInterface({ input: process.stdin, output: process.stderr });
117
- rl.question(question, (answer) => {
118
- rl.close();
119
- resolve(/^y(es)?$/i.test(answer.trim()));
120
- });
121
- });
122
- }
123
- export async function runCleanupCLI(argv) {
124
- const parsed = parseArgs(argv);
125
- if (parsed.kind === "help") {
126
- console.log(USAGE.trim());
127
- return 0;
128
- }
129
- if (parsed.kind === "error") {
130
- console.error(`Error: ${parsed.message}`);
131
- console.error(USAGE.trim());
132
- return 2;
133
- }
134
- const opts = parsed.opts;
135
- const nowMs = Date.now();
136
- const maxAgeMs = opts.all ? undefined : (opts.days ?? 0) * 86_400_000;
137
- const minAgeMs = opts.minAgeMinutes * 60_000;
138
- console.log(`Scanning ${opts.dir} for: ${opts.patterns.join(", ")}`);
139
- const candidates = await scanCandidates(opts.dir, opts.patterns);
140
- let orphanPaths = [];
141
- if (opts.sweepOrphans) {
142
- orphanPaths = await findOrphanSessionDirs(opts.dir);
143
- }
144
- const protectedPaths = [];
145
- const zendySrcDir = process.env["ZENDY_SRC_DIR"];
146
- if (zendySrcDir)
147
- protectedPaths.push(zendySrcDir);
148
- const classified = classifyCandidates(candidates, {
149
- nowMs,
150
- minAgeMs,
151
- maxAgeMs,
152
- keepLast: opts.keepLast,
153
- protectedPaths,
154
- });
155
- // Orphans are always eligible for deletion — their owning process is gone.
156
- // Merge them in, but never override a min-age or current-session protection.
157
- const toDeleteByPath = new Map();
158
- for (const c of classified.toDelete)
159
- toDeleteByPath.set(c.path, c);
160
- const protectedSet = new Set(classified.protected.map((c) => c.path));
161
- for (const pp of protectedPaths)
162
- protectedSet.add(pp);
163
- for (const orphanPath of orphanPaths) {
164
- if (protectedSet.has(orphanPath))
165
- continue;
166
- if (toDeleteByPath.has(orphanPath))
167
- continue;
168
- const cand = candidates.find((c) => c.path === orphanPath);
169
- if (cand)
170
- toDeleteByPath.set(cand.path, cand);
171
- }
172
- const toDelete = Array.from(toDeleteByPath.values());
173
- console.log(``);
174
- console.log(`Found ${candidates.length} matching director${candidates.length === 1 ? "y" : "ies"}:`);
175
- for (const c of [...candidates].sort((a, b) => b.mtimeMs - a.mtimeMs)) {
176
- const age = formatAge(nowMs - c.mtimeMs);
177
- const size = formatBytes(c.sizeBytes);
178
- let tag = "";
179
- if (protectedPaths.includes(c.path))
180
- tag = " [protected: current session]";
181
- else if (classified.protected.includes(c))
182
- tag = " [protected: within min-age]";
183
- else if (classified.kept.includes(c))
184
- tag = " [kept]";
185
- else if (toDeleteByPath.has(c.path)) {
186
- tag = orphanPaths.includes(c.path) ? " [delete: orphan session]" : " [delete]";
187
- }
188
- console.log(` ${c.path} ${age} ${size}${tag}`);
189
- }
190
- if (toDelete.length === 0) {
191
- console.log(``);
192
- console.log(`Nothing to delete.`);
193
- return 0;
194
- }
195
- const totalBytes = toDelete.reduce((a, b) => a + b.sizeBytes, 0);
196
- console.log(``);
197
- console.log(`Will delete ${toDelete.length} director${toDelete.length === 1 ? "y" : "ies"}, freeing ${formatBytes(totalBytes)}.`);
198
- if (opts.dryRun) {
199
- console.log(``);
200
- console.log(`[--dry-run] Nothing actually deleted. Re-run without --dry-run to delete.`);
201
- return 0;
202
- }
203
- if (!opts.yes && process.stdin.isTTY) {
204
- const ok = await promptYesNo(`Proceed? [y/N] `);
205
- if (!ok) {
206
- console.log(`Cancelled.`);
207
- return 0;
208
- }
209
- }
210
- const result = await deleteCandidates(toDelete);
211
- console.log(``);
212
- console.log(`Deleted ${result.deleted.length} director${result.deleted.length === 1 ? "y" : "ies"}, freed ${formatBytes(result.bytesFreed)}.`);
213
- for (const e of result.errors) {
214
- console.error(` ! ${e.path}: ${e.error}`);
215
- }
216
- return result.errors.length > 0 ? 1 : 0;
217
- }
package/dist/index.d.ts DELETED
@@ -1,2 +0,0 @@
1
- #!/usr/bin/env node
2
- export {};
package/dist/index.js DELETED
@@ -1,115 +0,0 @@
1
- #!/usr/bin/env node
2
- import { join, dirname } from "node:path";
3
- import { execFileSync, spawn } from "node:child_process";
4
- import { fileURLToPath } from "node:url";
5
- import { createRequire } from "node:module";
6
- import { runPreflight, printPreflightReport, promptCoreMissing } from "./preflight.js";
7
- const __filename = fileURLToPath(import.meta.url);
8
- const __dirname = dirname(__filename);
9
- const require = createRequire(import.meta.url);
10
- const pkg = require("../package.json");
11
- const VERSION = pkg.version;
12
- // Resolve package root (one level up from dist/)
13
- const PKG_ROOT = join(__dirname, "..");
14
- // The extension is loaded directly from the package's own extensions/ dir,
15
- // where zendy.ts and its sibling imports (tools.ts, commands.ts) live together.
16
- // This is the same file pi loads via the `pi.extensions` manifest field, so the
17
- // `zendy` launcher and `pi install` behave identically — no separate extraction step.
18
- const EXT_ZENDY = join(PKG_ROOT, "extensions", "zendy.ts");
19
- // Skill describing zendy and the ticket-analysis workflow. pi discovers it via
20
- // the pi.skills manifest field when installed as a package; the launcher passes
21
- // it explicitly so both load paths expose the same skill.
22
- const SKILL_ZENDY = join(PKG_ROOT, "skills", "zendy");
23
- function findPi() {
24
- try {
25
- return execFileSync("which", ["pi"], { encoding: "utf-8" }).trim();
26
- }
27
- catch {
28
- process.stderr.write("Error: `pi` not found in PATH.\n" +
29
- "Install it with: npm install -g @earendil-works/pi-coding-agent\n");
30
- process.exit(1);
31
- }
32
- }
33
- async function main() {
34
- const userArgs = process.argv.slice(2);
35
- // Intercept --version / -V / -v before passing to pi.
36
- // pi also recognises -v as a version flag, so without this interception
37
- // `zendy -v` would fall through and print pi's version, not zendy's.
38
- if (userArgs.includes("--version") ||
39
- userArgs.includes("-V") ||
40
- userArgs.includes("-v")) {
41
- console.log(`zendy ${VERSION}`);
42
- return;
43
- }
44
- // Intercept the cleanup-src subcommand — runs standalone, without pi.
45
- if (userArgs[0] === "cleanup-src") {
46
- const { runCleanupCLI } = await import("./cleanup-src.js");
47
- const code = await runCleanupCLI(userArgs.slice(1));
48
- process.exit(code);
49
- }
50
- // Intercept the preflight subcommand — runs the same checks as startup,
51
- // but on demand. `--json` emits machine-readable output (used by the
52
- // /status slash command extension).
53
- if (userArgs[0] === "preflight") {
54
- const wantJson = userArgs.includes("--json");
55
- const report = await runPreflight();
56
- if (wantJson) {
57
- process.stdout.write(JSON.stringify(report) + "\n");
58
- return;
59
- }
60
- printPreflightReport(report);
61
- if (report.results.every((r) => r.status === "ok")) {
62
- process.stdout.write("All checks passed.\n");
63
- }
64
- return;
65
- }
66
- // Skip preflight if user explicitly opts out
67
- if (!userArgs.includes("--skip-preflight")) {
68
- const report = await runPreflight();
69
- printPreflightReport(report);
70
- if (report.hasFatal) {
71
- process.exit(1);
72
- }
73
- if (report.hasCore) {
74
- // Only prompt interactively when stdin is a TTY;
75
- // in pipes / -p mode, just warn on stderr and continue.
76
- if (process.stdin.isTTY) {
77
- const shouldContinue = await promptCoreMissing();
78
- if (!shouldContinue) {
79
- process.exit(0);
80
- }
81
- }
82
- }
83
- }
84
- // Remove --skip-preflight before passing to pi
85
- const filteredArgs = userArgs.filter((a) => a !== "--skip-preflight");
86
- const pi = findPi();
87
- const args = [
88
- "--extension",
89
- EXT_ZENDY,
90
- "--skill",
91
- SKILL_ZENDY,
92
- ];
93
- // Pass through all user arguments
94
- args.push(...filteredArgs);
95
- // Spawn pi, replacing this process (inherit stdio for interactive use)
96
- const child = spawn(pi, args, {
97
- stdio: "inherit",
98
- });
99
- child.on("error", (err) => {
100
- process.stderr.write(`Failed to exec pi: ${err.message}\n`);
101
- process.exit(1);
102
- });
103
- child.on("exit", (code, signal) => {
104
- if (signal) {
105
- process.kill(process.pid, signal);
106
- }
107
- else {
108
- process.exit(code ?? 1);
109
- }
110
- });
111
- }
112
- main().catch((err) => {
113
- process.stderr.write(`zendy: ${err.message}\n`);
114
- process.exit(1);
115
- });
@@ -1,22 +0,0 @@
1
- export type CheckLevel = "fatal" | "core" | "enhanced";
2
- export type CheckStatus = "ok" | "missing" | "auth_error";
3
- export interface CheckResult {
4
- name: string;
5
- label: string;
6
- level: CheckLevel;
7
- status: CheckStatus;
8
- hint: string;
9
- }
10
- export interface PreflightReport {
11
- results: CheckResult[];
12
- hasFatal: boolean;
13
- hasCore: boolean;
14
- hasEnhanced: boolean;
15
- }
16
- export declare function runPreflight(): Promise<PreflightReport>;
17
- export declare function printPreflightReport(report: PreflightReport): void;
18
- /**
19
- * Interactive prompt for core dependency failures.
20
- * Returns true if the user wants to continue, false to exit.
21
- */
22
- export declare function promptCoreMissing(): Promise<boolean>;
package/dist/preflight.js DELETED
@@ -1,264 +0,0 @@
1
- import { execFile } from "node:child_process";
2
- import { existsSync } from "node:fs";
3
- import { join } from "node:path";
4
- import { homedir } from "node:os";
5
- import { createInterface } from "node:readline";
6
- import { resolveZendeskConfig, resolveKgConfig } from "./config/resolve.js";
7
- import { DEFAULT_KG_API_URL } from "./config/schema.js";
8
- // ── Helpers ────────────────────────────────────────────────────────────
9
- function exec(cmd, args, timeoutMs = 5000) {
10
- return new Promise((resolve) => {
11
- execFile(cmd, args, { timeout: timeoutMs, encoding: "utf-8" }, (err, stdout, stderr) => {
12
- if (err && err.code === "ENOENT") {
13
- resolve({ code: 127, stdout: "", stderr: "" });
14
- return;
15
- }
16
- // execFile callback err has an `exit code` when the process exits non-zero
17
- const exitCode = err ? (err.status ?? 1) : 0;
18
- resolve({ code: exitCode, stdout: stdout ?? "", stderr: stderr ?? "" });
19
- });
20
- });
21
- }
22
- // ── Individual checks ──────────────────────────────────────────────────
23
- // Common API key env vars that pi recognizes
24
- const PI_API_KEY_VARS = [
25
- "ANTHROPIC_API_KEY",
26
- "ANTHROPIC_OAUTH_TOKEN",
27
- "OPENAI_API_KEY",
28
- "GEMINI_API_KEY",
29
- "GROQ_API_KEY",
30
- "OPENROUTER_API_KEY",
31
- "XAI_API_KEY",
32
- "MISTRAL_API_KEY",
33
- "AWS_ACCESS_KEY_ID",
34
- ];
35
- async function checkPi() {
36
- const base = {
37
- name: "pi",
38
- label: "AI Agent (pi)",
39
- level: "fatal",
40
- };
41
- const result = await exec("pi", ["--version"]);
42
- if (result.code === 127) {
43
- return {
44
- ...base,
45
- status: "missing",
46
- hint: "Install pi: npm install -g @earendil-works/pi-coding-agent",
47
- };
48
- }
49
- // pi is installed — check if any auth is configured.
50
- // Auth can be in ~/.pi/agent/auth.json (OAuth login) or via API key env vars.
51
- const authFile = join(homedir(), ".pi", "agent", "auth.json");
52
- const hasFileAuth = existsSync(authFile);
53
- const hasEnvAuth = PI_API_KEY_VARS.some((v) => !!process.env[v]);
54
- if (!hasFileAuth && !hasEnvAuth) {
55
- return {
56
- ...base,
57
- status: "auth_error",
58
- hint: "pi is installed but no provider is configured.\nRun: pi (to log in interactively)\nOr set an API key: export GEMINI_API_KEY=...",
59
- };
60
- }
61
- return { ...base, status: "ok", hint: "" };
62
- }
63
- async function checkZendesk() {
64
- const base = {
65
- name: "zendesk",
66
- label: "Zendesk access",
67
- level: "core",
68
- };
69
- const cfg = resolveZendeskConfig();
70
- if (!cfg) {
71
- return {
72
- ...base,
73
- status: "missing",
74
- hint: "Zendesk credentials not configured.\nUse /zendy-config in pi, or set env: ZENDY_ZENDESK_SUBDOMAIN, ZENDY_ZENDESK_EMAIL, ZENDY_ZENDESK_API_TOKEN",
75
- };
76
- }
77
- try {
78
- const ctrl = new AbortController();
79
- const timeout = setTimeout(() => ctrl.abort(), 10000);
80
- const auth = "Basic " + Buffer.from(`${cfg.email}/token:${cfg.apiToken}`).toString("base64");
81
- const response = await fetch(`https://${cfg.subdomain}.zendesk.com/api/v2/users/me.json`, {
82
- headers: { Authorization: auth, Accept: "application/json", "User-Agent": "zendy-preflight" },
83
- signal: ctrl.signal,
84
- });
85
- clearTimeout(timeout);
86
- if (response.ok) {
87
- const data = await response.json();
88
- return { ...base, status: "ok", hint: `Authenticated as ${data.user?.email ?? "unknown"}` };
89
- }
90
- return {
91
- ...base,
92
- status: "auth_error",
93
- hint: `Zendesk API returned ${response.status}. Check credentials in ~/.zendy/config.json or env vars.`,
94
- };
95
- }
96
- catch (e) {
97
- const msg = e.message || String(e);
98
- return {
99
- ...base,
100
- status: "auth_error",
101
- hint: `Zendesk API unreachable: ${msg.slice(0, 200)}`,
102
- };
103
- }
104
- }
105
- async function checkZendeskKgApi() {
106
- const base = {
107
- name: "zendesk-kg",
108
- label: "Zendesk Knowledge Graph",
109
- level: "enhanced",
110
- };
111
- const cfg = resolveKgConfig();
112
- if (!cfg) {
113
- return {
114
- ...base,
115
- status: "missing",
116
- hint: "Knowledge Graph API key not configured.\nUse /zendy-config in pi, or set env: ZENDY_KG_API_KEY",
117
- };
118
- }
119
- try {
120
- const ctrl = new AbortController();
121
- const timeout = setTimeout(() => ctrl.abort(), 10000);
122
- const baseUrl = cfg.apiUrl || DEFAULT_KG_API_URL;
123
- const headers = {
124
- Accept: "application/json",
125
- Authorization: `Bearer ${cfg.apiKey}`,
126
- "User-Agent": "zendy-preflight",
127
- };
128
- if (cfg.apiKey)
129
- headers["x-api-key"] = cfg.apiKey;
130
- const response = await fetch(`${baseUrl}/health`, { headers, signal: ctrl.signal });
131
- clearTimeout(timeout);
132
- if (response.ok) {
133
- const data = await response.json();
134
- return { ...base, status: "ok", hint: `Status: ${data.status ?? "ok"}` };
135
- }
136
- return {
137
- ...base,
138
- status: "auth_error",
139
- hint: `KG API returned ${response.status}. Check credentials.`,
140
- };
141
- }
142
- catch (e) {
143
- const msg = e.message || String(e);
144
- return {
145
- ...base,
146
- status: "auth_error",
147
- hint: `KG API unreachable: ${msg.slice(0, 200)}`,
148
- };
149
- }
150
- }
151
- async function checkGithub() {
152
- const base = {
153
- name: "github",
154
- label: "GitHub access",
155
- level: "enhanced",
156
- };
157
- const result = await exec("ssh", ["-T", "-o", "ConnectTimeout=3", "-o", "StrictHostKeyChecking=no", "git@github.com"]);
158
- // ssh -T git@github.com exits 1 on success (GitHub prints "Hi user!")
159
- // exits 255 on auth failure, and 127 if ssh missing
160
- if (result.code === 127) {
161
- return {
162
- ...base,
163
- status: "missing",
164
- hint: "SSH client not found. Install OpenSSH to enable source code analysis.",
165
- };
166
- }
167
- const combined = result.stdout + result.stderr;
168
- if (combined.includes("successfully authenticated") || combined.includes("Hi ")) {
169
- return { ...base, status: "ok", hint: "" };
170
- }
171
- return {
172
- ...base,
173
- status: "auth_error",
174
- hint: "GitHub SSH not configured. Source code analysis will be unavailable.\nSet up SSH keys: https://docs.github.com/en/authentication/connecting-to-github-with-ssh",
175
- };
176
- }
177
- // ── Preflight runner ───────────────────────────────────────────────────
178
- export async function runPreflight() {
179
- const results = await Promise.all([checkPi(), checkZendesk(), checkZendeskKgApi(), checkGithub()]);
180
- const failed = results.filter((r) => r.status !== "ok");
181
- return {
182
- results,
183
- hasFatal: failed.some((r) => r.level === "fatal"),
184
- hasCore: failed.some((r) => r.level === "core"),
185
- hasEnhanced: failed.some((r) => r.level === "enhanced"),
186
- };
187
- }
188
- // ── Terminal display ───────────────────────────────────────────────────
189
- const RESET = "\x1b[0m";
190
- const BOLD = "\x1b[1m";
191
- const RED = "\x1b[31m";
192
- const YELLOW = "\x1b[33m";
193
- const GREEN = "\x1b[32m";
194
- const DIM = "\x1b[2m";
195
- function statusIcon(status) {
196
- if (status === "ok")
197
- return `${GREEN}✓${RESET}`;
198
- return `${RED}✗${RESET}`;
199
- }
200
- export function printPreflightReport(report) {
201
- const failed = report.results.filter((r) => r.status !== "ok");
202
- if (failed.length === 0)
203
- return; // all good, stay silent
204
- console.error("");
205
- console.error(`${BOLD}Zendy setup check${RESET}`);
206
- console.error("");
207
- for (const r of report.results) {
208
- console.error(` ${statusIcon(r.status)} ${r.label}`);
209
- }
210
- console.error("");
211
- // Fatal — hard stop
212
- if (report.hasFatal) {
213
- const fatal = failed.filter((r) => r.level === "fatal");
214
- for (const r of fatal) {
215
- console.error(`${RED}${BOLD}Error:${RESET} ${r.label} is required but not available.`);
216
- console.error(`${DIM}${r.hint}${RESET}`);
217
- console.error("");
218
- }
219
- return;
220
- }
221
- // Core missing — strong warning with options
222
- if (report.hasCore) {
223
- const core = failed.filter((r) => r.level === "core");
224
- for (const r of core) {
225
- console.error(`${YELLOW}${BOLD}Warning:${RESET} ${r.label} is not configured.`);
226
- console.error(`${DIM}${r.hint}${RESET}`);
227
- console.error("");
228
- }
229
- }
230
- // Enhanced missing — light banner
231
- if (report.hasEnhanced) {
232
- const enhanced = failed.filter((r) => r.level === "enhanced");
233
- for (const r of enhanced) {
234
- console.error(`${DIM}Note: ${r.label} is not configured. ${r.hint.split("\n")[0]}${RESET}`);
235
- }
236
- console.error("");
237
- }
238
- }
239
- /**
240
- * Interactive prompt for core dependency failures.
241
- * Returns true if the user wants to continue, false to exit.
242
- */
243
- export function promptCoreMissing() {
244
- return new Promise((resolve) => {
245
- const rl = createInterface({
246
- input: process.stdin,
247
- output: process.stderr,
248
- });
249
- console.error(`${BOLD}What would you like to do?${RESET}`);
250
- console.error(` ${BOLD}1${RESET} Continue with limited capabilities`);
251
- console.error(` ${BOLD}2${RESET} Exit and configure manually`);
252
- console.error("");
253
- rl.question(`${DIM}Choose [1/2]: ${RESET}`, (answer) => {
254
- rl.close();
255
- const choice = answer.trim();
256
- if (choice === "2") {
257
- resolve(false);
258
- }
259
- else {
260
- resolve(true); // default: continue
261
- }
262
- });
263
- });
264
- }
@@ -1,43 +0,0 @@
1
- export interface CandidateDir {
2
- path: string;
3
- name: string;
4
- mtimeMs: number;
5
- sizeBytes: number;
6
- }
7
- export interface ClassifyOptions {
8
- nowMs: number;
9
- /** Skip any dir whose mtime is within this window (0 disables). Wins over everything. */
10
- minAgeMs: number;
11
- /** Only delete dirs older than this (undefined = no age limit, i.e. --all). */
12
- maxAgeMs?: number;
13
- /** Keep the N most-recent candidates regardless of age. */
14
- keepLast: number;
15
- /** Absolute paths that must never be deleted (e.g. the current session dir). */
16
- protectedPaths?: string[];
17
- }
18
- export interface ClassifyResult {
19
- toDelete: CandidateDir[];
20
- kept: CandidateDir[];
21
- protected: CandidateDir[];
22
- }
23
- export interface DeleteResult {
24
- deleted: string[];
25
- bytesFreed: number;
26
- errors: {
27
- path: string;
28
- error: string;
29
- }[];
30
- }
31
- export declare function scanCandidates(baseDir: string, patterns: string[]): Promise<CandidateDir[]>;
32
- export declare function classifyCandidates(candidates: CandidateDir[], opts: ClassifyOptions): ClassifyResult;
33
- export declare function deleteCandidates(candidates: CandidateDir[]): Promise<DeleteResult>;
34
- export interface SessionDirInfo {
35
- pid: number;
36
- timestamp: number;
37
- }
38
- export declare function parseSessionDirName(name: string): SessionDirInfo | null;
39
- export declare function buildSessionDirName(pid: number, timestamp: number): string;
40
- export declare function isProcessAlive(pid: number): boolean;
41
- export declare function findOrphanSessionDirs(baseDir: string): Promise<string[]>;
42
- export declare function formatBytes(n: number): string;
43
- export declare function formatAge(ageMs: number): string;
@@ -1,188 +0,0 @@
1
- // Source clone directory cleanup library.
2
- //
3
- // Dify enterprise repositories are private; every extra minute their source
4
- // sits on local disk is an extra minute of exposure surface. This module
5
- // provides the primitives to enumerate, classify, and wipe those clones.
6
- //
7
- // Used by:
8
- // - `src/cleanup-src.ts` — the `zendy cleanup-src` CLI subcommand.
9
- // - `extensions/zendy.ts` — the pi extension inlines the tiny session-dir
10
- // helpers it needs so it can be loaded as a self-contained TS file by pi
11
- // at runtime.
12
- import { readdir, stat, rm } from "node:fs/promises";
13
- import { join } from "node:path";
14
- async function dirSize(path) {
15
- let total = 0;
16
- const stack = [path];
17
- while (stack.length) {
18
- const cur = stack.pop();
19
- let entries;
20
- try {
21
- entries = await readdir(cur, { withFileTypes: true });
22
- }
23
- catch {
24
- continue;
25
- }
26
- for (const e of entries) {
27
- if (e.isSymbolicLink())
28
- continue;
29
- const full = join(cur, e.name);
30
- if (e.isDirectory()) {
31
- stack.push(full);
32
- }
33
- else if (e.isFile()) {
34
- try {
35
- const s = await stat(full);
36
- total += s.size;
37
- }
38
- catch {
39
- // unreadable file — skip
40
- }
41
- }
42
- }
43
- }
44
- return total;
45
- }
46
- function matchesAnyPattern(name, patterns) {
47
- return patterns.some((p) => {
48
- if (p.endsWith("*"))
49
- return name.startsWith(p.slice(0, -1));
50
- return name === p;
51
- });
52
- }
53
- export async function scanCandidates(baseDir, patterns) {
54
- let entries;
55
- try {
56
- entries = await readdir(baseDir, { withFileTypes: true });
57
- }
58
- catch {
59
- return [];
60
- }
61
- const results = [];
62
- for (const e of entries) {
63
- if (!e.isDirectory())
64
- continue; // skip files + symlinks
65
- if (!matchesAnyPattern(e.name, patterns))
66
- continue;
67
- const full = join(baseDir, e.name);
68
- let st;
69
- try {
70
- st = await stat(full);
71
- }
72
- catch {
73
- continue;
74
- }
75
- const size = await dirSize(full);
76
- results.push({ path: full, name: e.name, mtimeMs: st.mtimeMs, sizeBytes: size });
77
- }
78
- return results;
79
- }
80
- export function classifyCandidates(candidates, opts) {
81
- const protectedSet = new Set(opts.protectedPaths ?? []);
82
- const sorted = [...candidates].sort((a, b) => b.mtimeMs - a.mtimeMs);
83
- const toDelete = [];
84
- const kept = [];
85
- const protectedOut = [];
86
- let keptByKeepLast = 0;
87
- for (const c of sorted) {
88
- if (protectedSet.has(c.path)) {
89
- protectedOut.push(c);
90
- continue;
91
- }
92
- const ageMs = opts.nowMs - c.mtimeMs;
93
- if (opts.minAgeMs > 0 && ageMs < opts.minAgeMs) {
94
- protectedOut.push(c);
95
- continue;
96
- }
97
- if (keptByKeepLast < opts.keepLast) {
98
- kept.push(c);
99
- keptByKeepLast++;
100
- continue;
101
- }
102
- if (opts.maxAgeMs !== undefined && ageMs < opts.maxAgeMs) {
103
- kept.push(c);
104
- continue;
105
- }
106
- toDelete.push(c);
107
- }
108
- return { toDelete, kept, protected: protectedOut };
109
- }
110
- export async function deleteCandidates(candidates) {
111
- const deleted = [];
112
- const errors = [];
113
- let bytesFreed = 0;
114
- for (const c of candidates) {
115
- try {
116
- await rm(c.path, { recursive: true, force: true });
117
- deleted.push(c.path);
118
- bytesFreed += c.sizeBytes;
119
- }
120
- catch (err) {
121
- errors.push({ path: c.path, error: err.message });
122
- }
123
- }
124
- return { deleted, bytesFreed, errors };
125
- }
126
- export function parseSessionDirName(name) {
127
- const m = /^zendy-session-(\d+)-(\d+)$/.exec(name);
128
- if (!m)
129
- return null;
130
- return { pid: parseInt(m[1], 10), timestamp: parseInt(m[2], 10) };
131
- }
132
- export function buildSessionDirName(pid, timestamp) {
133
- return `zendy-session-${pid}-${timestamp}`;
134
- }
135
- export function isProcessAlive(pid) {
136
- try {
137
- process.kill(pid, 0);
138
- return true;
139
- }
140
- catch (err) {
141
- // EPERM: process exists but we can't signal it; it's still alive.
142
- return err.code === "EPERM";
143
- }
144
- }
145
- export async function findOrphanSessionDirs(baseDir) {
146
- let entries;
147
- try {
148
- entries = await readdir(baseDir, { withFileTypes: true });
149
- }
150
- catch {
151
- return [];
152
- }
153
- const orphans = [];
154
- for (const e of entries) {
155
- if (!e.isDirectory())
156
- continue;
157
- const info = parseSessionDirName(e.name);
158
- if (!info)
159
- continue;
160
- if (!isProcessAlive(info.pid)) {
161
- orphans.push(join(baseDir, e.name));
162
- }
163
- }
164
- return orphans;
165
- }
166
- // ── Formatting helpers ────────────────────────────────────────────────
167
- export function formatBytes(n) {
168
- if (n < 1024)
169
- return `${n} B`;
170
- if (n < 1024 * 1024)
171
- return `${(n / 1024).toFixed(1)} KB`;
172
- if (n < 1024 * 1024 * 1024)
173
- return `${(n / 1024 / 1024).toFixed(1)} MB`;
174
- return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
175
- }
176
- export function formatAge(ageMs) {
177
- const s = Math.floor(ageMs / 1000);
178
- if (s < 60)
179
- return `${s}s`;
180
- const m = Math.floor(s / 60);
181
- if (m < 60)
182
- return `${m}m`;
183
- const h = Math.floor(m / 60);
184
- if (h < 24)
185
- return `${h}h`;
186
- const d = Math.floor(h / 24);
187
- return `${d}d`;
188
- }