@xpufx/paseo-slash 0.1.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.
Files changed (102) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +59 -0
  3. package/client/commands.ts +122 -0
  4. package/client/console.tsx +484 -0
  5. package/client/vendor/paseo-plugin-helper/command-center.ts +43 -0
  6. package/client/vendor/paseo-plugin-helper/components/AboutSection.tsx +493 -0
  7. package/client/vendor/paseo-plugin-helper/components/AttentionBeacon.tsx +250 -0
  8. package/client/vendor/paseo-plugin-helper/components/Badge.tsx +156 -0
  9. package/client/vendor/paseo-plugin-helper/components/Button.tsx +178 -0
  10. package/client/vendor/paseo-plugin-helper/components/Card.tsx +225 -0
  11. package/client/vendor/paseo-plugin-helper/components/CodeBlock.tsx +196 -0
  12. package/client/vendor/paseo-plugin-helper/components/Collapsible.tsx +277 -0
  13. package/client/vendor/paseo-plugin-helper/components/CommandBox.tsx +172 -0
  14. package/client/vendor/paseo-plugin-helper/components/CopyButton.tsx +180 -0
  15. package/client/vendor/paseo-plugin-helper/components/DataTable.tsx +200 -0
  16. package/client/vendor/paseo-plugin-helper/components/EmptyState.tsx +97 -0
  17. package/client/vendor/paseo-plugin-helper/components/HighlightedText.tsx +70 -0
  18. package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +73 -0
  19. package/client/vendor/paseo-plugin-helper/components/KeyValue.tsx +446 -0
  20. package/client/vendor/paseo-plugin-helper/components/MetricGauge.tsx +247 -0
  21. package/client/vendor/paseo-plugin-helper/components/ProgressBar.tsx +117 -0
  22. package/client/vendor/paseo-plugin-helper/components/Responsive.tsx +53 -0
  23. package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +118 -0
  24. package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +80 -0
  25. package/client/vendor/paseo-plugin-helper/components/Select.tsx +215 -0
  26. package/client/vendor/paseo-plugin-helper/components/StatusDot.tsx +80 -0
  27. package/client/vendor/paseo-plugin-helper/components/Tabs.tsx +319 -0
  28. package/client/vendor/paseo-plugin-helper/components/TextInput.tsx +150 -0
  29. package/client/vendor/paseo-plugin-helper/components/Toggle.tsx +163 -0
  30. package/client/vendor/paseo-plugin-helper/components/TruncatedText.tsx +157 -0
  31. package/client/vendor/paseo-plugin-helper/components/index.ts +25 -0
  32. package/client/vendor/paseo-plugin-helper/custom-pills.tsx +224 -0
  33. package/client/vendor/paseo-plugin-helper/forge-icon.tsx +79 -0
  34. package/client/vendor/paseo-plugin-helper/host.ts +277 -0
  35. package/client/vendor/paseo-plugin-helper/icon.tsx +39 -0
  36. package/client/vendor/paseo-plugin-helper/index.ts +28 -0
  37. package/client/vendor/paseo-plugin-helper/layout/ActionBar.tsx +49 -0
  38. package/client/vendor/paseo-plugin-helper/layout/FormRow.tsx +103 -0
  39. package/client/vendor/paseo-plugin-helper/layout/Grid.tsx +65 -0
  40. package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +378 -0
  41. package/client/vendor/paseo-plugin-helper/layout/ModalContent.tsx +49 -0
  42. package/client/vendor/paseo-plugin-helper/layout/Row.tsx +39 -0
  43. package/client/vendor/paseo-plugin-helper/layout/Stack.tsx +39 -0
  44. package/client/vendor/paseo-plugin-helper/layout/index.ts +7 -0
  45. package/client/vendor/paseo-plugin-helper/panel.tsx +81 -0
  46. package/client/vendor/paseo-plugin-helper/pill.tsx +884 -0
  47. package/client/vendor/paseo-plugin-helper/query-refresh.ts +79 -0
  48. package/client/vendor/paseo-plugin-helper/query.ts +66 -0
  49. package/client/vendor/paseo-plugin-helper/settings-screen.tsx +372 -0
  50. package/client/vendor/paseo-plugin-helper/settings.ts +181 -0
  51. package/client/vendor/paseo-plugin-helper/shared-settings.ts +46 -0
  52. package/client/vendor/paseo-plugin-helper/snapshot.ts +68 -0
  53. package/client/vendor/paseo-plugin-helper/surface.tsx +80 -0
  54. package/client/vendor/paseo-plugin-helper/theme/color-utils.ts +118 -0
  55. package/client/vendor/paseo-plugin-helper/theme/flair.ts +76 -0
  56. package/client/vendor/paseo-plugin-helper/theme/host-variables.ts +121 -0
  57. package/client/vendor/paseo-plugin-helper/theme/index.ts +7 -0
  58. package/client/vendor/paseo-plugin-helper/theme/provider.tsx +214 -0
  59. package/client/vendor/paseo-plugin-helper/theme/responsive.ts +213 -0
  60. package/client/vendor/paseo-plugin-helper/theme/tokens.ts +161 -0
  61. package/client/vendor/paseo-plugin-helper/theme/useResponsive.ts +57 -0
  62. package/client/vendor/paseo-plugin-helper/utils/clipboard.ts +149 -0
  63. package/client/vendor/paseo-plugin-helper/utils/haptics.ts +34 -0
  64. package/docs/screenshots/slash-autocomplete.jpg +0 -0
  65. package/docs/screenshots/slash-console.jpg +0 -0
  66. package/package.json +27 -0
  67. package/paseo-plugin.json +4 -0
  68. package/server/orchestrate.ts +81 -0
  69. package/server/resources.ts +132 -0
  70. package/server/vendor/paseo-plugin-helper/agent.ts +85 -0
  71. package/server/vendor/paseo-plugin-helper/custom-pills.ts +344 -0
  72. package/server/vendor/paseo-plugin-helper/index.ts +18 -0
  73. package/server/vendor/paseo-plugin-helper/jsonc.ts +78 -0
  74. package/server/vendor/paseo-plugin-helper/logger.ts +210 -0
  75. package/server/vendor/paseo-plugin-helper/mcp-config.ts +367 -0
  76. package/server/vendor/paseo-plugin-helper/mcp-injection.ts +85 -0
  77. package/server/vendor/paseo-plugin-helper/network.ts +91 -0
  78. package/server/vendor/paseo-plugin-helper/plugins.ts +160 -0
  79. package/server/vendor/paseo-plugin-helper/process.ts +186 -0
  80. package/server/vendor/paseo-plugin-helper/redact.ts +86 -0
  81. package/server/vendor/paseo-plugin-helper/rpc-guard.ts +77 -0
  82. package/server/vendor/paseo-plugin-helper/settings.ts +97 -0
  83. package/server/vendor/paseo-plugin-helper/shared-settings.ts +243 -0
  84. package/server/vendor/paseo-plugin-helper/storage.ts +244 -0
  85. package/server/vendor/paseo-plugin-helper/system.ts +128 -0
  86. package/server/vendor/paseo-plugin-helper/task.ts +116 -0
  87. package/server/vendor/paseo-plugin-helper/version.ts +153 -0
  88. package/server/vendor/paseo-plugin-helper/workspace-beacon.ts +418 -0
  89. package/shared/resources.ts +289 -0
  90. package/shared/vendor/paseo-plugin-helper/README.md +11 -0
  91. package/shared/vendor/paseo-plugin-helper/async.ts +35 -0
  92. package/shared/vendor/paseo-plugin-helper/custom-pills.ts +169 -0
  93. package/shared/vendor/paseo-plugin-helper/forge.ts +110 -0
  94. package/shared/vendor/paseo-plugin-helper/formatters.ts +271 -0
  95. package/shared/vendor/paseo-plugin-helper/highlight.ts +184 -0
  96. package/shared/vendor/paseo-plugin-helper/index.ts +10 -0
  97. package/shared/vendor/paseo-plugin-helper/rpc.ts +72 -0
  98. package/shared/vendor/paseo-plugin-helper/settings.ts +138 -0
  99. package/shared/vendor/paseo-plugin-helper/suite-settings.ts +17 -0
  100. package/shared/vendor/paseo-plugin-helper/suppressed.ts +31 -0
  101. package/shared/vendor/paseo-plugin-helper/types.ts +36 -0
  102. package/shared/version.ts +2 -0
@@ -0,0 +1,344 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { parseJsonc } from "./jsonc";
4
+ import { safeExec } from "./process";
5
+ import type { PluginLogger } from "./logger";
6
+ import {
7
+ CustomPillDefinitionSchema,
8
+ type CustomPillDefinition,
9
+ type CustomPillState,
10
+ resolveCustomPillStatus,
11
+ parseNumericPillValue,
12
+ formatPillDisplay,
13
+ } from "../../../shared/vendor/paseo-plugin-helper/custom-pills";
14
+
15
+
16
+ /**
17
+ * Discovers and validates all custom pill configuration files (.json / .jsonc)
18
+ * from a directory (e.g. ~/.paseo/top/pills or ~/.paseo/custom-pills).
19
+ */
20
+ export async function discoverCustomPillConfigs(
21
+ dirPath: string,
22
+ logger?: PluginLogger,
23
+ ): Promise<CustomPillDefinition[]> {
24
+ try {
25
+ if (!fs.existsSync(dirPath)) {
26
+ return [];
27
+ }
28
+
29
+ const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
30
+ const configs: CustomPillDefinition[] = [];
31
+
32
+ for (const entry of entries) {
33
+ if (
34
+ !entry.isFile() ||
35
+ (!entry.name.endsWith(".json") && !entry.name.endsWith(".jsonc"))
36
+ ) {
37
+ continue;
38
+ }
39
+
40
+ const filePath = path.join(dirPath, entry.name);
41
+ try {
42
+ const rawContent = await fs.promises.readFile(filePath, "utf-8");
43
+ const parsed = parseJsonc(rawContent);
44
+
45
+ const result = CustomPillDefinitionSchema.safeParse(parsed);
46
+ if (result.success) {
47
+ configs.push({ ...result.data, sourceFile: filePath });
48
+ } else {
49
+ logger?.warn(
50
+ `Invalid custom pill config in ${entry.name}: ${result.error.issues.map((i) => i.message).join(", ")}`,
51
+ );
52
+ }
53
+ } catch (err) {
54
+ logger?.warn(
55
+ `Failed to read custom pill config from ${entry.name}: ${err instanceof Error ? err.message : String(err)}`,
56
+ );
57
+ }
58
+ }
59
+
60
+ return configs;
61
+ } catch (err) {
62
+ logger?.warn(
63
+ `Error reading custom pills directory ${dirPath}: ${err instanceof Error ? err.message : String(err)}`,
64
+ );
65
+ return [];
66
+ }
67
+ }
68
+
69
+ export interface CustomPillPollerOptions {
70
+ /**
71
+ * Initial list of custom pill definitions.
72
+ */
73
+ pills?: CustomPillDefinition[];
74
+
75
+ /**
76
+ * Optional directory to discover .json / .jsonc configs from.
77
+ */
78
+ configDir?: string;
79
+
80
+ /**
81
+ * Custom environment variables passed to all executed commands
82
+ * (e.g. PASEO_AGENT_ID, PASEO_WORKSPACE_ID).
83
+ */
84
+ env?: Record<string, string>;
85
+
86
+ /**
87
+ * Working directory for executed commands. Defaults to process.cwd().
88
+ */
89
+ cwd?: string;
90
+
91
+ /**
92
+ * Optional structured logger.
93
+ */
94
+ logger?: PluginLogger;
95
+
96
+ /**
97
+ * Callback fired whenever any custom pill state changes.
98
+ */
99
+ onUpdate?: (states: CustomPillState[]) => void;
100
+ }
101
+
102
+ /**
103
+ * Managed server poller for user-defined declarative custom metric pills.
104
+ * Periodically executes shell commands, computes statuses via thresholds,
105
+ * and maintains reactive live state.
106
+ */
107
+ export class CustomPillPoller {
108
+ private pills = new Map<string, CustomPillDefinition>();
109
+ private states = new Map<string, CustomPillState>();
110
+ private timers = new Map<string, ReturnType<typeof setTimeout>>();
111
+ private inFlight = new Set<string>();
112
+ private running = false;
113
+ private options: CustomPillPollerOptions;
114
+
115
+ constructor(options: CustomPillPollerOptions = {}) {
116
+ this.options = options;
117
+ if (options.pills) {
118
+ for (const pill of options.pills) {
119
+ this.pills.set(pill.id, pill);
120
+ }
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Starts the polling loops for all configured custom pills.
126
+ */
127
+ async start(): Promise<void> {
128
+ if (this.running) return;
129
+ this.running = true;
130
+
131
+ if (this.options.configDir) {
132
+ const discovered = await discoverCustomPillConfigs(
133
+ this.options.configDir,
134
+ this.options.logger,
135
+ );
136
+ for (const pill of discovered) {
137
+ this.pills.set(pill.id, pill);
138
+ }
139
+ }
140
+
141
+ for (const pill of this.pills.values()) {
142
+ if (pill.enabled) {
143
+ this.schedulePill(pill, 0);
144
+ }
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Manually triggers an immediate execution of a single custom pill.
150
+ */
151
+ async pollPill(pillId: string): Promise<CustomPillState | undefined> {
152
+ const pill = this.pills.get(pillId);
153
+ if (!pill) return undefined;
154
+ if (this.inFlight.has(pillId)) {
155
+ return this.states.get(pillId);
156
+ }
157
+
158
+ this.inFlight.add(pillId);
159
+ try {
160
+ const result = await safeExec(pill.command, {
161
+ timeoutMs: pill.timeoutMs,
162
+ env: { ...process.env, ...this.options.env },
163
+ cwd: this.options.cwd,
164
+ });
165
+
166
+ const rawValue = result.stdout || result.stderr || "";
167
+ const numericValue = parseNumericPillValue(rawValue);
168
+ const status = resolveCustomPillStatus(numericValue, pill.thresholds);
169
+ const displayValue = formatPillDisplay(rawValue, pill.prefix, pill.suffix);
170
+
171
+ const state: CustomPillState = {
172
+ id: pill.id,
173
+ title: pill.title,
174
+ compactTitle: pill.compactTitle,
175
+ icon: pill.icon,
176
+ compactIcon: pill.compactIcon,
177
+ rawValue,
178
+ displayValue,
179
+ numericValue,
180
+ status,
181
+ lastUpdated: Date.now(),
182
+ sourceFile: pill.sourceFile,
183
+ modalTitle: pill.modal?.title ?? pill.title,
184
+ modalDescription: pill.modal?.description,
185
+ };
186
+
187
+ this.states.set(pill.id, state);
188
+ this.notifyUpdate();
189
+ return state;
190
+ } catch (err) {
191
+ const errorMsg = err instanceof Error ? err.message : String(err);
192
+ this.options.logger?.warn(`Custom pill '${pill.id}' execution failed: ${errorMsg}`);
193
+
194
+ const previous = this.states.get(pill.id);
195
+ const state: CustomPillState = {
196
+ id: pill.id,
197
+ title: pill.title,
198
+ compactTitle: pill.compactTitle,
199
+ icon: pill.icon,
200
+ compactIcon: pill.compactIcon,
201
+ rawValue: previous?.rawValue ?? "ERR",
202
+ displayValue: previous?.displayValue ?? "ERR",
203
+ status: "danger",
204
+ lastUpdated: Date.now(),
205
+ error: errorMsg,
206
+ sourceFile: pill.sourceFile,
207
+ modalTitle: pill.modal?.title ?? pill.title,
208
+ modalDescription: pill.modal?.description,
209
+ };
210
+
211
+ this.states.set(pill.id, state);
212
+ this.notifyUpdate();
213
+ return state;
214
+ } finally {
215
+ this.inFlight.delete(pillId);
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Executes the on-demand drilldown command configured in pill.modal.command.
221
+ */
222
+ async runModalCommand(
223
+ pillId: string,
224
+ ): Promise<{ output?: string; error?: string }> {
225
+ const pill = this.pills.get(pillId);
226
+ if (!pill) {
227
+ return { error: `Custom pill '${pillId}' not found` };
228
+ }
229
+
230
+ const commandToRun = pill.modal?.command ?? pill.command;
231
+ try {
232
+ const result = await safeExec(commandToRun, {
233
+ timeoutMs: pill.timeoutMs,
234
+ env: { ...process.env, ...this.options.env },
235
+ cwd: this.options.cwd,
236
+ });
237
+
238
+ const output = result.stdout || result.stderr || "No output";
239
+ const existing = this.states.get(pillId);
240
+ if (existing) {
241
+ existing.modalOutput = output;
242
+ existing.modalLastUpdated = Date.now();
243
+ delete existing.modalError;
244
+ this.notifyUpdate();
245
+ }
246
+
247
+ return { output };
248
+ } catch (err) {
249
+ const errorMsg = err instanceof Error ? err.message : String(err);
250
+ const existing = this.states.get(pillId);
251
+ if (existing) {
252
+ existing.modalError = errorMsg;
253
+ this.notifyUpdate();
254
+ }
255
+ return { error: errorMsg };
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Updates or reconciles the list of pill definitions dynamically.
261
+ */
262
+ updatePills(newPills: CustomPillDefinition[]): void {
263
+ const nextIds = new Set(newPills.map((p) => p.id));
264
+
265
+ // Remove obsolete pills
266
+ for (const [id, timer] of this.timers.entries()) {
267
+ if (!nextIds.has(id)) {
268
+ clearTimeout(timer);
269
+ this.timers.delete(id);
270
+ this.pills.delete(id);
271
+ this.states.delete(id);
272
+ }
273
+ }
274
+
275
+ // Add or update pills
276
+ for (const pill of newPills) {
277
+ this.pills.set(pill.id, pill);
278
+ const currentTimer = this.timers.get(pill.id);
279
+ if (currentTimer) {
280
+ clearTimeout(currentTimer);
281
+ this.timers.delete(pill.id);
282
+ }
283
+
284
+ if (this.running && pill.enabled) {
285
+ this.schedulePill(pill, 0);
286
+ }
287
+ }
288
+
289
+ this.notifyUpdate();
290
+ }
291
+
292
+ /**
293
+ * Returns live state for a single custom pill.
294
+ */
295
+ getState(pillId: string): CustomPillState | undefined {
296
+ return this.states.get(pillId);
297
+ }
298
+
299
+ /**
300
+ * Returns live states for all custom pills.
301
+ */
302
+ getAllStates(): CustomPillState[] {
303
+ return Array.from(this.states.values());
304
+ }
305
+
306
+ /**
307
+ * Stops all active polling loops and clears resources.
308
+ */
309
+ stop(): void {
310
+ this.running = false;
311
+ for (const timer of this.timers.values()) {
312
+ clearTimeout(timer);
313
+ }
314
+ this.timers.clear();
315
+ this.inFlight.clear();
316
+ }
317
+
318
+ private schedulePill(pill: CustomPillDefinition, delayMs: number): void {
319
+ if (!this.running) return;
320
+
321
+ // @ts-ignore
322
+ const timer = setTimeout(async () => {
323
+ await this.pollPill(pill.id);
324
+ if (this.running && this.pills.has(pill.id)) {
325
+ const nextPill = this.pills.get(pill.id);
326
+ if (nextPill?.enabled) {
327
+ this.schedulePill(nextPill, nextPill.intervalMs);
328
+ }
329
+ }
330
+ }, delayMs as any);
331
+
332
+ this.timers.set(pill.id, timer);
333
+ }
334
+
335
+ private notifyUpdate(): void {
336
+ if (this.options.onUpdate) {
337
+ try {
338
+ this.options.onUpdate(this.getAllStates());
339
+ } catch {
340
+ // Prevent callback errors from disrupting poller
341
+ }
342
+ }
343
+ }
344
+ }
@@ -0,0 +1,18 @@
1
+ export * from "./storage";
2
+ export * from "./settings";
3
+ export * from "./shared-settings";
4
+ export * from "./jsonc";
5
+ export * from "./redact";
6
+ export * from "./process";
7
+ export * from "./system";
8
+ export * from "./logger";
9
+ export * from "./version";
10
+ export * from "./network";
11
+ export * from "./task";
12
+ export * from "./mcp-config";
13
+ export * from "./mcp-injection";
14
+ export * from "./plugins";
15
+ export * from "./custom-pills";
16
+ export * from "./rpc-guard";
17
+ export * from "./agent";
18
+ export * from "./workspace-beacon";
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Strips single-line and multi-line comments and trailing commas from a JSONC string
3
+ * without altering strings or URLs.
4
+ */
5
+ export function stripJsonComments(text: string): string {
6
+ let insideString = false;
7
+ let stringDelimiter = "";
8
+ let isEscaped = false;
9
+ let result = "";
10
+
11
+ for (let i = 0; i < text.length; i++) {
12
+ const char = text[i];
13
+ const nextChar = text[i + 1];
14
+
15
+ if (insideString) {
16
+ result += char;
17
+ if (isEscaped) {
18
+ isEscaped = false;
19
+ } else if (char === "\\") {
20
+ isEscaped = true;
21
+ } else if (char === stringDelimiter) {
22
+ insideString = false;
23
+ }
24
+ continue;
25
+ }
26
+
27
+ if (char === '"' || char === "'") {
28
+ insideString = true;
29
+ stringDelimiter = char;
30
+ result += char;
31
+ continue;
32
+ }
33
+
34
+ // Line comment
35
+ if (char === "/" && nextChar === "/") {
36
+ while (i < text.length && text[i] !== "\n" && text[i] !== "\r") {
37
+ i++;
38
+ }
39
+ if (i < text.length) result += text[i];
40
+ continue;
41
+ }
42
+
43
+ // Block comment
44
+ if (char === "/" && nextChar === "*") {
45
+ i += 2;
46
+ while (i < text.length - 1 && !(text[i] === "*" && text[i + 1] === "/")) {
47
+ i++;
48
+ }
49
+ i++; // Skip closing '/'
50
+ continue;
51
+ }
52
+
53
+ result += char;
54
+ }
55
+
56
+ // Remove trailing commas before } or ]
57
+ return result.replace(/,\s*([}\]])/g, "$1");
58
+ }
59
+
60
+ /**
61
+ * Safely parses a JSONC (JSON with comments and trailing commas) string.
62
+ */
63
+ export function parseJsonc<T = unknown>(text: string): T {
64
+ const sanitized = stripJsonComments(text.trim());
65
+ return JSON.parse(sanitized) as T;
66
+ }
67
+
68
+ /**
69
+ * Safely parses a JSONC string without throwing errors.
70
+ * Returns the provided fallback value if parsing fails.
71
+ */
72
+ export function tryParseJsonc<T>(text: string, fallback: T): T {
73
+ try {
74
+ return parseJsonc<T>(text);
75
+ } catch {
76
+ return fallback;
77
+ }
78
+ }
@@ -0,0 +1,210 @@
1
+ import { redactSecrets } from "./redact";
2
+ import { resolvePluginVersion } from "./version";
3
+
4
+ export type LogLevel = "debug" | "info" | "warn" | "error";
5
+
6
+ const LEVEL_SEVERITY: Record<LogLevel, number> = {
7
+ debug: 10,
8
+ info: 20,
9
+ warn: 30,
10
+ error: 40,
11
+ };
12
+
13
+ function isLogLevel(value: unknown): value is LogLevel {
14
+ return (
15
+ value === "debug" || value === "info" || value === "warn" || value === "error"
16
+ );
17
+ }
18
+
19
+ /**
20
+ * Resolves the minimum log level from the environment. Precedence:
21
+ * `PASEO_PLUGIN_LOG_LEVEL` > `PASEO_LOG_LEVEL` > `PASEO_DEBUG=1` (debug).
22
+ * Returns undefined when nothing is set so callers can apply their default.
23
+ */
24
+ export function resolveMinLevelFromEnv(
25
+ env: NodeJS.ProcessEnv = process.env,
26
+ ): LogLevel | undefined {
27
+ const raw = env.PASEO_PLUGIN_LOG_LEVEL ?? env.PASEO_LOG_LEVEL;
28
+ if (typeof raw === "string" && isLogLevel(raw.trim().toLowerCase())) {
29
+ return raw.trim().toLowerCase() as LogLevel;
30
+ }
31
+ const debugFlag = env.PASEO_DEBUG ?? env.PASEO_PLUGIN_DEBUG;
32
+ if (
33
+ typeof debugFlag === "string" &&
34
+ ["1", "true", "yes", "debug"].includes(debugFlag.trim().toLowerCase())
35
+ ) {
36
+ return "debug";
37
+ }
38
+ return undefined;
39
+ }
40
+
41
+ /**
42
+ * True when running in production. Retained for callers that need a
43
+ * production check; note the default log level no longer keys off this.
44
+ */
45
+ export function isProductionEnv(env: NodeJS.ProcessEnv = process.env): boolean {
46
+ return (env.NODE_ENV ?? "").trim().toLowerCase() === "production";
47
+ }
48
+
49
+ /**
50
+ * True only when `NODE_ENV` is explicitly a development value
51
+ * (`development`/`dev`). Unset, empty, `production`, and anything else
52
+ * (e.g. `test`) all count as quiet, so a shipped plugin defaults to info
53
+ * without any env var set.
54
+ */
55
+ export function isDevelopmentEnv(env: NodeJS.ProcessEnv = process.env): boolean {
56
+ const nodeEnv = (env.NODE_ENV ?? "").trim().toLowerCase();
57
+ return nodeEnv === "development" || nodeEnv === "dev";
58
+ }
59
+
60
+ /**
61
+ * Default rule (documented for operators):
62
+ * explicit `PASEO_PLUGIN_LOG_LEVEL`/`PASEO_LOG_LEVEL`/`PASEO_DEBUG` always wins;
63
+ * otherwise quiet (`info`) — including when no env var is set at all, so a
64
+ * shipped plugin never emits debug logs by default. Debug only when `NODE_ENV`
65
+ * is explicitly a development value (`development`/`dev`).
66
+ */
67
+ export function resolveDefaultMinLevel(
68
+ env: NodeJS.ProcessEnv = process.env,
69
+ ): LogLevel {
70
+ return resolveMinLevelFromEnv(env) ?? (isDevelopmentEnv(env) ? "debug" : "info");
71
+ }
72
+
73
+ export interface PluginLoggerOptions {
74
+ /**
75
+ * Version of the plugin.
76
+ * If omitted, automatically resolves from `package.json` (augmented by git tag/hash).
77
+ */
78
+ version?: string;
79
+
80
+ /**
81
+ * Whether to emit a formatted startup banner on initialization.
82
+ * Defaults to `true`.
83
+ */
84
+ banner?: boolean;
85
+
86
+ /**
87
+ * Subsystem or module tag within the plugin (e.g. "poller", "mcp-client").
88
+ */
89
+ subsystem?: string;
90
+
91
+ /**
92
+ * Minimum log level to print. Defaults to resolveDefaultMinLevel():
93
+ * info unless NODE_ENV is explicitly development (or an explicit level is set).
94
+ */
95
+ minLevel?: LogLevel;
96
+
97
+ /**
98
+ * Optional custom metadata key-values to include in the startup banner.
99
+ */
100
+ meta?: Record<string, string | number | boolean>;
101
+ }
102
+
103
+ export interface PluginLogger {
104
+ debug(message: string, data?: unknown): void;
105
+ info(message: string, data?: unknown): void;
106
+ warn(message: string, data?: unknown): void;
107
+ error(message: string, data?: unknown): void;
108
+ /** Surfaces a caught/suppressed error at debug level so it reaches the plugin log. */
109
+ suppressed(context: string, error: unknown): void;
110
+ child(subsystemOrOptions: string | Partial<PluginLoggerOptions>): PluginLogger;
111
+ }
112
+
113
+ function formatData(data: unknown): string {
114
+ if (data === undefined) return "";
115
+ if (data instanceof Error) {
116
+ return `error="${data.message}"${data.stack ? `\n${data.stack}` : ""}`;
117
+ }
118
+
119
+ const sanitized = redactSecrets(data);
120
+ if (typeof sanitized === "object" && sanitized !== null && !Array.isArray(sanitized)) {
121
+ const pairs = Object.entries(sanitized as Record<string, unknown>).map(
122
+ ([k, v]) => `${k}=${typeof v === "object" ? JSON.stringify(v) : String(v)}`,
123
+ );
124
+ return pairs.join(" ");
125
+ }
126
+
127
+ return typeof sanitized === "string" ? sanitized : JSON.stringify(sanitized);
128
+ }
129
+
130
+ /**
131
+ * Creates a structured logger for Paseo plugins.
132
+ * By default, displays the plugin name and version in startup logs and tags each line
133
+ * for Paseo's log stream without fragmented multi-line JSON.
134
+ *
135
+ * If `options.version` is omitted, it automatically resolves the version from `package.json`
136
+ * augmented with git metadata.
137
+ */
138
+ export function createPluginLogger(
139
+ pluginId: string,
140
+ options: PluginLoggerOptions = {},
141
+ ): PluginLogger {
142
+ const resolvedVer = options.version ?? resolvePluginVersion({ fallback: "" });
143
+ const {
144
+ banner = true,
145
+ subsystem,
146
+ minLevel = resolveDefaultMinLevel(),
147
+ meta = {},
148
+ } = options;
149
+
150
+ const minSeverity = LEVEL_SEVERITY[minLevel];
151
+
152
+ // Prefix format: "[name vX.Y.Z]" or "[name]" or "[name vX.Y.Z:subsystem]"
153
+ const versionTag = resolvedVer ? ` v${resolvedVer}` : "";
154
+ const subTag = subsystem ? `:${subsystem}` : "";
155
+ const baseTag = `[${pluginId}${versionTag}${subTag}]`;
156
+
157
+ if (banner) {
158
+ const bannerDetails = [
159
+ `pid ${process.pid}`,
160
+ `node ${process.version}`,
161
+ ...Object.entries(meta).map(([k, v]) => `${k} ${v}`),
162
+ ].join(", ");
163
+
164
+ // Emit startup banner directly to stdout
165
+ console.log(`${baseTag} Initializing plugin (${bannerDetails})`);
166
+ }
167
+
168
+ function emit(level: LogLevel, message: string, data?: unknown) {
169
+ if (LEVEL_SEVERITY[level] < minSeverity) return;
170
+
171
+ const levelTag = `[${level.toUpperCase()}]`;
172
+ const formattedData = formatData(data);
173
+ const line = formattedData
174
+ ? `${baseTag} ${levelTag} ${message} ${formattedData}`
175
+ : `${baseTag} ${levelTag} ${message}`;
176
+
177
+ if (level === "error" || level === "warn") {
178
+ console.error(line);
179
+ } else {
180
+ console.log(line);
181
+ }
182
+ }
183
+
184
+ return {
185
+ debug(message: string, data?: unknown) {
186
+ emit("debug", message, data);
187
+ },
188
+ info(message: string, data?: unknown) {
189
+ emit("info", message, data);
190
+ },
191
+ warn(message: string, data?: unknown) {
192
+ emit("warn", message, data);
193
+ },
194
+ error(message: string, data?: unknown) {
195
+ emit("error", message, data);
196
+ },
197
+ suppressed(context: string, error: unknown) {
198
+ const detail = error instanceof Error ? error.message : String(error);
199
+ emit("debug", `${context}: ${detail}`, error);
200
+ },
201
+ child(subsystemOrOptions: string | Partial<PluginLoggerOptions>): PluginLogger {
202
+ const childOptions: PluginLoggerOptions =
203
+ typeof subsystemOrOptions === "string"
204
+ ? { ...options, version: resolvedVer, banner: false, subsystem: subsystemOrOptions }
205
+ : { ...options, version: resolvedVer, banner: false, ...subsystemOrOptions };
206
+
207
+ return createPluginLogger(pluginId, childOptions);
208
+ },
209
+ };
210
+ }