@tplog/pi-zendy 0.3.7 → 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 +1 -12
- package/extensions/tools.ts +81 -21
- package/package.json +6 -5
- package/skills/zendy/SKILL.md +60 -0
- package/dist/cleanup-src.d.ts +0 -1
- package/dist/cleanup-src.js +0 -217
- package/dist/index.d.ts +0 -2
- package/dist/index.js +0 -109
- package/dist/preflight.d.ts +0 -22
- package/dist/preflight.js +0 -264
- package/dist/source-cleanup.d.ts +0 -43
- package/dist/source-cleanup.js +0 -188
package/README.md
CHANGED
|
@@ -12,6 +12,7 @@ zendy is a single pi extension that provides:
|
|
|
12
12
|
|
|
13
13
|
- **LLM Tools** — Direct API access to Zendesk, Helm Watchdog, and Knowledge Graph. No external CLI dependencies.
|
|
14
14
|
- **Slash Commands** — `/zendy-config` to set up credentials, `/zendy-status` to check connectivity.
|
|
15
|
+
- **Skill** — a `zendy` skill (also `/skill:zendy`) that teaches the agent the ticket-analysis workflow, so "analyze ticket #1959" reliably uses the right tools in the right order.
|
|
15
16
|
- **Session Safety** — Automatic workspace isolation and cleanup for source code analysis.
|
|
16
17
|
|
|
17
18
|
Typical workflow:
|
|
@@ -74,18 +75,6 @@ The agent can call these tools directly:
|
|
|
74
75
|
| `zendy_kg_search` | Semantic search over historical tickets |
|
|
75
76
|
| `zendy_source_status` | Check source analysis workspace |
|
|
76
77
|
|
|
77
|
-
## Legacy Launcher
|
|
78
|
-
|
|
79
|
-
For users of the old `zendy` CLI:
|
|
80
|
-
|
|
81
|
-
```bash
|
|
82
|
-
npm install -g @tplog/pi-zendy
|
|
83
|
-
zendy
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
The legacy launcher starts pi with the zendy extension and system prompt. It still supports
|
|
87
|
-
`zendy preflight` and `zendy cleanup-src` as standalone subcommands.
|
|
88
|
-
|
|
89
78
|
## How it works
|
|
90
79
|
|
|
91
80
|
zendy registers as a pi extension package. The extension provides tools (callable by the LLM),
|
package/extensions/tools.ts
CHANGED
|
@@ -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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
+
"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",
|
|
@@ -19,12 +16,16 @@
|
|
|
19
16
|
"pi": {
|
|
20
17
|
"extensions": [
|
|
21
18
|
"./extensions/zendy.ts"
|
|
19
|
+
],
|
|
20
|
+
"skills": [
|
|
21
|
+
"./skills"
|
|
22
22
|
]
|
|
23
23
|
},
|
|
24
24
|
"files": [
|
|
25
25
|
"dist",
|
|
26
26
|
"extensions",
|
|
27
|
-
"prompts"
|
|
27
|
+
"prompts",
|
|
28
|
+
"skills"
|
|
28
29
|
],
|
|
29
30
|
"publishConfig": {
|
|
30
31
|
"access": "public"
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: zendy
|
|
3
|
+
description: >
|
|
4
|
+
Dify Enterprise support ticket analysis using the zendy tools. Use whenever the
|
|
5
|
+
user mentions a Zendesk ticket number or asks to analyze, investigate, or reply to
|
|
6
|
+
a support ticket (e.g. "analyze ticket #1959", "分析工单", "工单分析", "チケットを調査"),
|
|
7
|
+
asks about a customer's Dify Helm chart values or version defaults, wants to find
|
|
8
|
+
similar historical tickets, or needs a customer reply drafted. Covers the full
|
|
9
|
+
workflow: fetch ticket → identify Dify version → check Helm values → search
|
|
10
|
+
similar tickets → draft reply in the ticket's language.
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# zendy — Dify Enterprise Support Ticket Analysis
|
|
14
|
+
|
|
15
|
+
zendy is a support-engineering harness for Dify Enterprise. It registers tools for
|
|
16
|
+
Zendesk, the Dify Helm Watchdog, and a historical-ticket Knowledge Graph, so a
|
|
17
|
+
support engineer can analyze a ticket end-to-end without leaving the terminal.
|
|
18
|
+
|
|
19
|
+
## Tools
|
|
20
|
+
|
|
21
|
+
| Tool | Use for |
|
|
22
|
+
|------|---------|
|
|
23
|
+
| `zendy_ticket_get` | Fetch one Zendesk ticket with comments, requester, assignee |
|
|
24
|
+
| `zendy_ticket_search` | Search live Zendesk tickets (Zendesk search syntax) |
|
|
25
|
+
| `zendy_whoami` | Verify which Zendesk account is authenticated |
|
|
26
|
+
| `zendy_helm_get` | Dify Helm chart data by exact version: values.yaml, images, validation, latest |
|
|
27
|
+
| `zendy_kg_search` | Semantic search over historical tickets; returns ticketIds |
|
|
28
|
+
| `zendy_source_status` | Check the source-analysis workspace path and authorization rules |
|
|
29
|
+
|
|
30
|
+
Use these tools for all data access. Do not use zcli, curl, or any external Zendesk
|
|
31
|
+
CLI. If credentials are missing, tell the user to run `/zendy-config`; check
|
|
32
|
+
connectivity with `/zendy-status`.
|
|
33
|
+
|
|
34
|
+
## Ticket analysis workflow
|
|
35
|
+
|
|
36
|
+
1. User gives a ticket number → `zendy_ticket_get` to pull ticket + comments.
|
|
37
|
+
2. Identify the customer's exact Dify version from ticket fields or the thread.
|
|
38
|
+
3. Pull the matching chart data with `zendy_helm_get` — always pass the exact
|
|
39
|
+
version. Defaults change between releases; never assume a config value from a
|
|
40
|
+
different version.
|
|
41
|
+
4. Optionally `zendy_kg_search` for similar past tickets. Always cite the
|
|
42
|
+
`ticketId` values from results so the engineer can read the source tickets.
|
|
43
|
+
Empty results do not prove no similar issue exists.
|
|
44
|
+
5. Synthesize findings and draft a reply.
|
|
45
|
+
|
|
46
|
+
## Behavior rules
|
|
47
|
+
|
|
48
|
+
- **Never guess.** Base conclusions on actual data — ticket fields, values.yaml,
|
|
49
|
+
knowledge-graph results. State clearly what is fact vs. inference.
|
|
50
|
+
- **Don't recommend unverified solutions.** If something was not confirmed in
|
|
51
|
+
config or docs, say so explicitly.
|
|
52
|
+
- **Reply drafts match the ticket language.** Detect the thread language
|
|
53
|
+
(Japanese, Chinese, or English) and draft in that language with appropriate
|
|
54
|
+
business tone.
|
|
55
|
+
- **Source code analysis requires explicit user permission.** If config-level
|
|
56
|
+
analysis cannot settle the question, ask the user whether to check the source
|
|
57
|
+
(e.g. "需要我去源码确认吗?") — never clone without permission. Use
|
|
58
|
+
`zendy_source_status` to find the workspace.
|
|
59
|
+
- **Inspect before filtering.** On the first call to an unfamiliar response shape,
|
|
60
|
+
look at the structure before writing filter expressions; never assume field names.
|
package/dist/cleanup-src.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export declare function runCleanupCLI(argv: string[]): Promise<number>;
|
package/dist/cleanup-src.js
DELETED
|
@@ -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
package/dist/index.js
DELETED
|
@@ -1,109 +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
|
-
function findPi() {
|
|
20
|
-
try {
|
|
21
|
-
return execFileSync("which", ["pi"], { encoding: "utf-8" }).trim();
|
|
22
|
-
}
|
|
23
|
-
catch {
|
|
24
|
-
process.stderr.write("Error: `pi` not found in PATH.\n" +
|
|
25
|
-
"Install it with: npm install -g @earendil-works/pi-coding-agent\n");
|
|
26
|
-
process.exit(1);
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
async function main() {
|
|
30
|
-
const userArgs = process.argv.slice(2);
|
|
31
|
-
// Intercept --version / -V / -v before passing to pi.
|
|
32
|
-
// pi also recognises -v as a version flag, so without this interception
|
|
33
|
-
// `zendy -v` would fall through and print pi's version, not zendy's.
|
|
34
|
-
if (userArgs.includes("--version") ||
|
|
35
|
-
userArgs.includes("-V") ||
|
|
36
|
-
userArgs.includes("-v")) {
|
|
37
|
-
console.log(`zendy ${VERSION}`);
|
|
38
|
-
return;
|
|
39
|
-
}
|
|
40
|
-
// Intercept the cleanup-src subcommand — runs standalone, without pi.
|
|
41
|
-
if (userArgs[0] === "cleanup-src") {
|
|
42
|
-
const { runCleanupCLI } = await import("./cleanup-src.js");
|
|
43
|
-
const code = await runCleanupCLI(userArgs.slice(1));
|
|
44
|
-
process.exit(code);
|
|
45
|
-
}
|
|
46
|
-
// Intercept the preflight subcommand — runs the same checks as startup,
|
|
47
|
-
// but on demand. `--json` emits machine-readable output (used by the
|
|
48
|
-
// /status slash command extension).
|
|
49
|
-
if (userArgs[0] === "preflight") {
|
|
50
|
-
const wantJson = userArgs.includes("--json");
|
|
51
|
-
const report = await runPreflight();
|
|
52
|
-
if (wantJson) {
|
|
53
|
-
process.stdout.write(JSON.stringify(report) + "\n");
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
56
|
-
printPreflightReport(report);
|
|
57
|
-
if (report.results.every((r) => r.status === "ok")) {
|
|
58
|
-
process.stdout.write("All checks passed.\n");
|
|
59
|
-
}
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
62
|
-
// Skip preflight if user explicitly opts out
|
|
63
|
-
if (!userArgs.includes("--skip-preflight")) {
|
|
64
|
-
const report = await runPreflight();
|
|
65
|
-
printPreflightReport(report);
|
|
66
|
-
if (report.hasFatal) {
|
|
67
|
-
process.exit(1);
|
|
68
|
-
}
|
|
69
|
-
if (report.hasCore) {
|
|
70
|
-
// Only prompt interactively when stdin is a TTY;
|
|
71
|
-
// in pipes / -p mode, just warn on stderr and continue.
|
|
72
|
-
if (process.stdin.isTTY) {
|
|
73
|
-
const shouldContinue = await promptCoreMissing();
|
|
74
|
-
if (!shouldContinue) {
|
|
75
|
-
process.exit(0);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
// Remove --skip-preflight before passing to pi
|
|
81
|
-
const filteredArgs = userArgs.filter((a) => a !== "--skip-preflight");
|
|
82
|
-
const pi = findPi();
|
|
83
|
-
const args = [
|
|
84
|
-
"--extension",
|
|
85
|
-
EXT_ZENDY,
|
|
86
|
-
];
|
|
87
|
-
// Pass through all user arguments
|
|
88
|
-
args.push(...filteredArgs);
|
|
89
|
-
// Spawn pi, replacing this process (inherit stdio for interactive use)
|
|
90
|
-
const child = spawn(pi, args, {
|
|
91
|
-
stdio: "inherit",
|
|
92
|
-
});
|
|
93
|
-
child.on("error", (err) => {
|
|
94
|
-
process.stderr.write(`Failed to exec pi: ${err.message}\n`);
|
|
95
|
-
process.exit(1);
|
|
96
|
-
});
|
|
97
|
-
child.on("exit", (code, signal) => {
|
|
98
|
-
if (signal) {
|
|
99
|
-
process.kill(process.pid, signal);
|
|
100
|
-
}
|
|
101
|
-
else {
|
|
102
|
-
process.exit(code ?? 1);
|
|
103
|
-
}
|
|
104
|
-
});
|
|
105
|
-
}
|
|
106
|
-
main().catch((err) => {
|
|
107
|
-
process.stderr.write(`zendy: ${err.message}\n`);
|
|
108
|
-
process.exit(1);
|
|
109
|
-
});
|
package/dist/preflight.d.ts
DELETED
|
@@ -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
|
-
}
|
package/dist/source-cleanup.d.ts
DELETED
|
@@ -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;
|
package/dist/source-cleanup.js
DELETED
|
@@ -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
|
-
}
|