trickle-cli 0.1.214 → 0.1.216
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/dist/commands/hints.d.ts +17 -0
- package/dist/commands/hints.js +385 -0
- package/dist/index.js +10 -0
- package/package.json +1 -1
- package/src/commands/hints.ts +379 -0
- package/src/index.ts +11 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output source code with inline type hints (like VSCode inlay hints).
|
|
3
|
+
*
|
|
4
|
+
* Reads .trickle/variables.jsonl and renders the source with type
|
|
5
|
+
* annotations inserted inline — suitable for AI agents that need
|
|
6
|
+
* to understand runtime types without running the code.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* trickle hints src/app.py # all hints for a file
|
|
10
|
+
* trickle hints src/app.py --values # include sample values
|
|
11
|
+
* trickle hints # all observed files
|
|
12
|
+
*/
|
|
13
|
+
export interface HintsOptions {
|
|
14
|
+
values?: boolean;
|
|
15
|
+
errors?: boolean;
|
|
16
|
+
}
|
|
17
|
+
export declare function hintsCommand(targetFile: string | undefined, opts: HintsOptions): Promise<void>;
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Output source code with inline type hints (like VSCode inlay hints).
|
|
4
|
+
*
|
|
5
|
+
* Reads .trickle/variables.jsonl and renders the source with type
|
|
6
|
+
* annotations inserted inline — suitable for AI agents that need
|
|
7
|
+
* to understand runtime types without running the code.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* trickle hints src/app.py # all hints for a file
|
|
11
|
+
* trickle hints src/app.py --values # include sample values
|
|
12
|
+
* trickle hints # all observed files
|
|
13
|
+
*/
|
|
14
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
15
|
+
if (k2 === undefined) k2 = k;
|
|
16
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
17
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
18
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
19
|
+
}
|
|
20
|
+
Object.defineProperty(o, k2, desc);
|
|
21
|
+
}) : (function(o, m, k, k2) {
|
|
22
|
+
if (k2 === undefined) k2 = k;
|
|
23
|
+
o[k2] = m[k];
|
|
24
|
+
}));
|
|
25
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
26
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
27
|
+
}) : function(o, v) {
|
|
28
|
+
o["default"] = v;
|
|
29
|
+
});
|
|
30
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
31
|
+
var ownKeys = function(o) {
|
|
32
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
33
|
+
var ar = [];
|
|
34
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
35
|
+
return ar;
|
|
36
|
+
};
|
|
37
|
+
return ownKeys(o);
|
|
38
|
+
};
|
|
39
|
+
return function (mod) {
|
|
40
|
+
if (mod && mod.__esModule) return mod;
|
|
41
|
+
var result = {};
|
|
42
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
43
|
+
__setModuleDefault(result, mod);
|
|
44
|
+
return result;
|
|
45
|
+
};
|
|
46
|
+
})();
|
|
47
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
48
|
+
exports.hintsCommand = hintsCommand;
|
|
49
|
+
const fs = __importStar(require("fs"));
|
|
50
|
+
const path = __importStar(require("path"));
|
|
51
|
+
function typeToString(node) {
|
|
52
|
+
if (!node)
|
|
53
|
+
return "unknown";
|
|
54
|
+
switch (node.kind) {
|
|
55
|
+
case "primitive": return node.name || "unknown";
|
|
56
|
+
case "object": {
|
|
57
|
+
if (!node.properties)
|
|
58
|
+
return node.class_name || "object";
|
|
59
|
+
if (node.class_name === "Tensor" || node.class_name === "ndarray") {
|
|
60
|
+
const shape = node.properties["shape"]?.name;
|
|
61
|
+
const dtype = node.properties["dtype"]?.name;
|
|
62
|
+
const parts = [];
|
|
63
|
+
if (shape)
|
|
64
|
+
parts.push(`shape=${shape}`);
|
|
65
|
+
if (dtype)
|
|
66
|
+
parts.push(`dtype=${dtype}`);
|
|
67
|
+
return `${node.class_name}(${parts.join(", ")})`;
|
|
68
|
+
}
|
|
69
|
+
if (node.class_name === "DataFrame") {
|
|
70
|
+
const rows = node.properties["rows"]?.name;
|
|
71
|
+
const cols = node.properties["cols"]?.name;
|
|
72
|
+
const parts = [];
|
|
73
|
+
if (rows && cols)
|
|
74
|
+
parts.push(`${rows}x${cols}`);
|
|
75
|
+
return `DataFrame(${parts.join(", ")})`;
|
|
76
|
+
}
|
|
77
|
+
if (node.class_name) {
|
|
78
|
+
const keys = Object.keys(node.properties).slice(0, 4);
|
|
79
|
+
const extra = Object.keys(node.properties).length > 4 ? `, +${Object.keys(node.properties).length - 4}` : "";
|
|
80
|
+
return `${node.class_name}(${keys.join(", ")}${extra})`;
|
|
81
|
+
}
|
|
82
|
+
const props = Object.entries(node.properties).slice(0, 5)
|
|
83
|
+
.map(([k, v]) => `${k}: ${typeToString(v)}`);
|
|
84
|
+
const extra = Object.keys(node.properties).length > 5 ? `, +${Object.keys(node.properties).length - 5}` : "";
|
|
85
|
+
return `{${props.join(", ")}${extra}}`;
|
|
86
|
+
}
|
|
87
|
+
case "array": {
|
|
88
|
+
const elem = node.element;
|
|
89
|
+
if (elem?.kind === "union") {
|
|
90
|
+
const members = elem.elements || elem.members;
|
|
91
|
+
if (members && members.length > 0) {
|
|
92
|
+
const names = new Set(members.map(m => m.class_name).filter(Boolean));
|
|
93
|
+
if (names.size === 1)
|
|
94
|
+
return `${names.values().next().value}[]`;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return `${typeToString(elem || { kind: "primitive", name: "unknown" })}[]`;
|
|
98
|
+
}
|
|
99
|
+
case "tuple": return `[${(node.elements || []).map(typeToString).join(", ")}]`;
|
|
100
|
+
case "union": {
|
|
101
|
+
const members = node.elements || node.members;
|
|
102
|
+
if (!members)
|
|
103
|
+
return "unknown";
|
|
104
|
+
const names = new Set(members.map(m => m.class_name).filter(Boolean));
|
|
105
|
+
if (names.size === 1)
|
|
106
|
+
return names.values().next().value;
|
|
107
|
+
return members.map(typeToString).join(" | ");
|
|
108
|
+
}
|
|
109
|
+
case "function": return "Function";
|
|
110
|
+
default: return node.kind;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function formatSample(sample) {
|
|
114
|
+
if (sample === null || sample === undefined)
|
|
115
|
+
return "";
|
|
116
|
+
if (typeof sample === "string") {
|
|
117
|
+
if (sample.length > 40)
|
|
118
|
+
return `"${sample.substring(0, 37)}..."`;
|
|
119
|
+
return `"${sample}"`;
|
|
120
|
+
}
|
|
121
|
+
if (typeof sample === "number") {
|
|
122
|
+
return Number.isInteger(sample) ? String(sample) : sample.toFixed(4);
|
|
123
|
+
}
|
|
124
|
+
if (typeof sample === "boolean")
|
|
125
|
+
return String(sample);
|
|
126
|
+
if (Array.isArray(sample))
|
|
127
|
+
return `[...${sample.length} items]`;
|
|
128
|
+
const s = String(sample);
|
|
129
|
+
return s.length > 40 ? s.substring(0, 37) + "..." : s;
|
|
130
|
+
}
|
|
131
|
+
async function hintsCommand(targetFile, opts) {
|
|
132
|
+
const localDir = process.env.TRICKLE_LOCAL_DIR || path.join(process.cwd(), ".trickle");
|
|
133
|
+
const varsFile = path.join(localDir, "variables.jsonl");
|
|
134
|
+
if (!fs.existsSync(varsFile)) {
|
|
135
|
+
console.error("No trickle data found. Run your app with trickle first:");
|
|
136
|
+
console.error(" trickle run python app.py");
|
|
137
|
+
console.error(" trickle run node app.js");
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
// Load observations from variables.jsonl
|
|
141
|
+
const obsMap = new Map();
|
|
142
|
+
const errorSnaps = [];
|
|
143
|
+
const targetKinds = opts.errors ? ["error_snapshot"] : ["variable"];
|
|
144
|
+
for (const line of fs.readFileSync(varsFile, "utf-8").split("\n").filter(Boolean)) {
|
|
145
|
+
try {
|
|
146
|
+
const v = JSON.parse(line);
|
|
147
|
+
if (v.kind === "variable") {
|
|
148
|
+
const key = `${v.file}:${v.line}:${v.varName}`;
|
|
149
|
+
obsMap.set(key, v);
|
|
150
|
+
}
|
|
151
|
+
if (v.kind === "error_snapshot") {
|
|
152
|
+
errorSnaps.push(v);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
catch { }
|
|
156
|
+
}
|
|
157
|
+
// In error mode, use error snapshots; look up original assignment lines from regular vars
|
|
158
|
+
let vars;
|
|
159
|
+
if (opts.errors) {
|
|
160
|
+
if (errorSnaps.length === 0) {
|
|
161
|
+
console.error("No error snapshots found. Run code that produces an error first.");
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
vars = errorSnaps;
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
vars = [...obsMap.values()];
|
|
168
|
+
}
|
|
169
|
+
// Filter by target file
|
|
170
|
+
let filtered = vars;
|
|
171
|
+
if (targetFile) {
|
|
172
|
+
const normalized = targetFile.replace(/^\.\//, "");
|
|
173
|
+
filtered = vars.filter(v => {
|
|
174
|
+
const relPath = path.relative(process.cwd(), v.file);
|
|
175
|
+
return relPath.includes(normalized) || v.file.includes(normalized);
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
if (filtered.length === 0) {
|
|
179
|
+
console.error(targetFile ? `No observations found for "${targetFile}".` : "No observations found.");
|
|
180
|
+
process.exit(1);
|
|
181
|
+
}
|
|
182
|
+
// In error mode, resolve each snapshot var to its original assignment line
|
|
183
|
+
// by looking up the regular variable observations
|
|
184
|
+
if (opts.errors) {
|
|
185
|
+
for (const snap of filtered) {
|
|
186
|
+
// Find matching regular observation to get original line
|
|
187
|
+
for (const [, obs] of obsMap) {
|
|
188
|
+
if (obs.file === snap.file && obs.varName === snap.varName) {
|
|
189
|
+
snap.line = obs.line; // use original assignment line
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// Group by file
|
|
196
|
+
const byFile = new Map();
|
|
197
|
+
for (const v of filtered) {
|
|
198
|
+
if (!byFile.has(v.file))
|
|
199
|
+
byFile.set(v.file, []);
|
|
200
|
+
byFile.get(v.file).push(v);
|
|
201
|
+
}
|
|
202
|
+
for (const [absFile, fileVars] of byFile) {
|
|
203
|
+
let sourceLines = [];
|
|
204
|
+
let relPath = "";
|
|
205
|
+
// Handle notebook cell paths: __notebook__cell_N.py
|
|
206
|
+
const cellMatch = absFile.match(/__notebook__cell_(\d+)\.py$/);
|
|
207
|
+
if (cellMatch) {
|
|
208
|
+
// Try to find the notebook .ipynb in the same directory
|
|
209
|
+
const dir = path.dirname(absFile);
|
|
210
|
+
const cellIdx = parseInt(cellMatch[1]);
|
|
211
|
+
const ipynbFiles = fs.existsSync(dir)
|
|
212
|
+
? fs.readdirSync(dir).filter(f => f.endsWith(".ipynb"))
|
|
213
|
+
: [];
|
|
214
|
+
let foundCell = false;
|
|
215
|
+
for (const nbFile of ipynbFiles) {
|
|
216
|
+
const nbPath = path.join(dir, nbFile);
|
|
217
|
+
try {
|
|
218
|
+
const nb = JSON.parse(fs.readFileSync(nbPath, "utf-8"));
|
|
219
|
+
const cells = nb.cells || [];
|
|
220
|
+
// trickle's cellIdx is the Nth code cell execution (1-based).
|
|
221
|
+
// Try direct index first, then count code cells.
|
|
222
|
+
let cellSource;
|
|
223
|
+
// Try: cellIdx maps to 0-based index in all cells
|
|
224
|
+
if (cells[cellIdx - 1]?.cell_type === "code") {
|
|
225
|
+
cellSource = cells[cellIdx - 1].source;
|
|
226
|
+
}
|
|
227
|
+
// Fallback: count code cells
|
|
228
|
+
if (!cellSource) {
|
|
229
|
+
let codeCount = 0;
|
|
230
|
+
for (const c of cells) {
|
|
231
|
+
if (c.cell_type === "code") {
|
|
232
|
+
codeCount++;
|
|
233
|
+
if (codeCount === cellIdx) {
|
|
234
|
+
cellSource = c.source;
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (!cellSource)
|
|
241
|
+
continue;
|
|
242
|
+
sourceLines = (Array.isArray(cellSource) ? cellSource.join("") : String(cellSource)).split("\n");
|
|
243
|
+
relPath = `${path.relative(process.cwd(), nbPath)} [cell ${cellIdx}]`;
|
|
244
|
+
foundCell = true;
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (!foundCell) {
|
|
252
|
+
// Can't find source — output just the observations in a summary format
|
|
253
|
+
relPath = absFile.replace(/.*__notebook__/, "notebook ").replace(/\.py$/, "");
|
|
254
|
+
const maxLine = Math.max(...fileVars.map(v => v.line));
|
|
255
|
+
sourceLines = Array.from({ length: maxLine }, (_, i) => `# line ${i + 1}`);
|
|
256
|
+
// Place observations as standalone lines
|
|
257
|
+
const lineObs = new Map();
|
|
258
|
+
for (const v of fileVars) {
|
|
259
|
+
if (!lineObs.has(v.line))
|
|
260
|
+
lineObs.set(v.line, new Map());
|
|
261
|
+
lineObs.get(v.line).set(v.varName, v);
|
|
262
|
+
}
|
|
263
|
+
// Print header with error info if in error mode
|
|
264
|
+
const errorMsg = fileVars.find(v => v.error)?.error;
|
|
265
|
+
const errorLine = fileVars.find(v => v.errorLine)?.errorLine;
|
|
266
|
+
if (opts.errors && errorMsg) {
|
|
267
|
+
console.log(`# ${relPath} — ERROR`);
|
|
268
|
+
console.log(`# ${errorMsg}${errorLine ? ` (line ${errorLine})` : ""}`);
|
|
269
|
+
console.log(`# Variables at crash time:`);
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
console.log(`# ${relPath}`);
|
|
273
|
+
}
|
|
274
|
+
console.log("```python");
|
|
275
|
+
for (const [lineNo, obs] of [...lineObs.entries()].sort((a, b) => a[0] - b[0])) {
|
|
276
|
+
for (const v of obs.values()) {
|
|
277
|
+
const typeStr = typeToString(v.type);
|
|
278
|
+
const scope = v.funcName ? ` (in ${v.funcName})` : "";
|
|
279
|
+
const sampleStr = formatSample(v.sample);
|
|
280
|
+
// In error mode, always show values (crash-time state)
|
|
281
|
+
if ((opts.errors || opts.values) && v.sample !== undefined) {
|
|
282
|
+
console.log(`${v.varName}: ${typeStr} = ${sampleStr}${scope}`);
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
console.log(`${v.varName}: ${typeStr}${scope}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
console.log("```");
|
|
290
|
+
console.log("");
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
if (!fs.existsSync(absFile))
|
|
296
|
+
continue;
|
|
297
|
+
sourceLines = fs.readFileSync(absFile, "utf-8").split("\n");
|
|
298
|
+
relPath = path.relative(process.cwd(), absFile);
|
|
299
|
+
}
|
|
300
|
+
// Build line → varName → observation map
|
|
301
|
+
const lineObs = new Map();
|
|
302
|
+
for (const v of fileVars) {
|
|
303
|
+
if (!lineObs.has(v.line))
|
|
304
|
+
lineObs.set(v.line, new Map());
|
|
305
|
+
lineObs.get(v.line).set(v.varName, v);
|
|
306
|
+
}
|
|
307
|
+
// Print header with error info if in error mode
|
|
308
|
+
const errMsg = fileVars.find(v => v.error)?.error;
|
|
309
|
+
const errLine = fileVars.find(v => v.errorLine)?.errorLine;
|
|
310
|
+
if (opts.errors && errMsg) {
|
|
311
|
+
console.log(`# ${relPath} — ERROR`);
|
|
312
|
+
console.log(`# ${errMsg}${errLine ? ` (line ${errLine})` : ""}`);
|
|
313
|
+
console.log(`# Variables at crash time:`);
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
console.log(`# ${relPath}`);
|
|
317
|
+
}
|
|
318
|
+
console.log("```python");
|
|
319
|
+
for (let i = 0; i < sourceLines.length; i++) {
|
|
320
|
+
const lineNo = i + 1;
|
|
321
|
+
const src = sourceLines[i];
|
|
322
|
+
const obs = lineObs.get(lineNo);
|
|
323
|
+
if (!obs || obs.size === 0) {
|
|
324
|
+
console.log(src);
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
// Insert type hints inline after variable names
|
|
328
|
+
let annotated = src;
|
|
329
|
+
// Sort by position in line (rightmost first so indices don't shift)
|
|
330
|
+
const entries = [...obs.values()].sort((a, b) => {
|
|
331
|
+
const aIdx = src.indexOf(a.varName);
|
|
332
|
+
const bIdx = src.indexOf(b.varName);
|
|
333
|
+
return bIdx - aIdx; // rightmost first
|
|
334
|
+
});
|
|
335
|
+
for (const v of entries) {
|
|
336
|
+
const typeStr = typeToString(v.type);
|
|
337
|
+
// Skip if the type is just "unknown" or not useful
|
|
338
|
+
if (typeStr === "unknown")
|
|
339
|
+
continue;
|
|
340
|
+
// Find variable in the line
|
|
341
|
+
const isAttr = v.varName.includes(".");
|
|
342
|
+
const pattern = isAttr
|
|
343
|
+
? new RegExp(v.varName.replace(/\./g, "\\."))
|
|
344
|
+
: new RegExp(`\\b${v.varName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
|
|
345
|
+
const match = pattern.exec(annotated);
|
|
346
|
+
if (!match)
|
|
347
|
+
continue;
|
|
348
|
+
const varEnd = match.index + v.varName.length;
|
|
349
|
+
const afterVar = annotated.substring(varEnd).trimStart();
|
|
350
|
+
// Check this is an assignment/declaration context
|
|
351
|
+
const beforeVar = annotated.substring(0, match.index);
|
|
352
|
+
const isPython = absFile.endsWith(".py");
|
|
353
|
+
if (isPython) {
|
|
354
|
+
const isAssignment = afterVar.startsWith("=") && !afterVar.startsWith("==");
|
|
355
|
+
const isAnnotated = afterVar.startsWith(":");
|
|
356
|
+
const isForVar = /\bfor\s+$/.test(beforeVar) || /\bfor\s+.*,\s*$/.test(beforeVar);
|
|
357
|
+
const isWithAs = /\bas\s+$/.test(beforeVar);
|
|
358
|
+
const isFuncParam = /\b(?:async\s+)?def\s+\w+\s*\(/.test(beforeVar) &&
|
|
359
|
+
(afterVar.startsWith(",") || afterVar.startsWith(")") || afterVar.startsWith("=") || afterVar.startsWith(":"));
|
|
360
|
+
const isBareAssign = /^\s*$/.test(beforeVar) || /,\s*$/.test(beforeVar);
|
|
361
|
+
if (!isForVar && !isWithAs && !isFuncParam && !((isBareAssign) && (isAssignment || isAnnotated)))
|
|
362
|
+
continue;
|
|
363
|
+
if (isAnnotated && !isFuncParam)
|
|
364
|
+
continue;
|
|
365
|
+
if (isFuncParam && afterVar.startsWith(":"))
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
// Build the hint string — error mode always shows values (crash-time state)
|
|
369
|
+
let hint;
|
|
370
|
+
if ((opts.errors || opts.values) && v.sample !== undefined && v.sample !== null) {
|
|
371
|
+
const sampleStr = formatSample(v.sample);
|
|
372
|
+
hint = sampleStr ? `: ${typeStr} = ${sampleStr}` : `: ${typeStr}`;
|
|
373
|
+
}
|
|
374
|
+
else {
|
|
375
|
+
hint = `: ${typeStr}`;
|
|
376
|
+
}
|
|
377
|
+
// Insert after variable name
|
|
378
|
+
annotated = annotated.substring(0, varEnd) + hint + annotated.substring(varEnd);
|
|
379
|
+
}
|
|
380
|
+
console.log(annotated);
|
|
381
|
+
}
|
|
382
|
+
console.log("```");
|
|
383
|
+
console.log("");
|
|
384
|
+
}
|
|
385
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -81,6 +81,7 @@ const mcp_server_1 = require("./commands/mcp-server");
|
|
|
81
81
|
const rn_1 = require("./commands/rn");
|
|
82
82
|
const next_1 = require("./commands/next");
|
|
83
83
|
const python_1 = require("./commands/python");
|
|
84
|
+
const hints_1 = require("./commands/hints");
|
|
84
85
|
const program = new commander_1.Command();
|
|
85
86
|
program
|
|
86
87
|
.name("trickle")
|
|
@@ -533,6 +534,15 @@ program
|
|
|
533
534
|
.action(async (opts) => {
|
|
534
535
|
await (0, vars_1.varsCommand)(opts);
|
|
535
536
|
});
|
|
537
|
+
// trickle hints
|
|
538
|
+
program
|
|
539
|
+
.command("hints [file]")
|
|
540
|
+
.description("Output source code with inline type hints from runtime observations (for AI agents)")
|
|
541
|
+
.option("--values", "Include sample values alongside types")
|
|
542
|
+
.option("--errors", "Show error mode — variables at crash time with values that caused the error")
|
|
543
|
+
.action(async (file, opts) => {
|
|
544
|
+
await (0, hints_1.hintsCommand)(file, opts);
|
|
545
|
+
});
|
|
536
546
|
// trickle layers
|
|
537
547
|
program
|
|
538
548
|
.command("layers")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trickle-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.216",
|
|
4
4
|
"description": "Zero-code runtime observability for JS/Python + AI agent debugging. Traces LangChain, CrewAI, OpenAI, Anthropic, Gemini. Eval, security, compliance, cost tracking. Free, local-first.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"observability",
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output source code with inline type hints (like VSCode inlay hints).
|
|
3
|
+
*
|
|
4
|
+
* Reads .trickle/variables.jsonl and renders the source with type
|
|
5
|
+
* annotations inserted inline — suitable for AI agents that need
|
|
6
|
+
* to understand runtime types without running the code.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* trickle hints src/app.py # all hints for a file
|
|
10
|
+
* trickle hints src/app.py --values # include sample values
|
|
11
|
+
* trickle hints # all observed files
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as fs from "fs";
|
|
15
|
+
import * as path from "path";
|
|
16
|
+
|
|
17
|
+
export interface HintsOptions {
|
|
18
|
+
values?: boolean;
|
|
19
|
+
errors?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface TypeNode {
|
|
23
|
+
kind: string;
|
|
24
|
+
name?: string;
|
|
25
|
+
elements?: TypeNode[];
|
|
26
|
+
members?: TypeNode[];
|
|
27
|
+
element?: TypeNode;
|
|
28
|
+
properties?: Record<string, TypeNode>;
|
|
29
|
+
class_name?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface VarObservation {
|
|
33
|
+
kind: string;
|
|
34
|
+
varName: string;
|
|
35
|
+
line: number;
|
|
36
|
+
file: string;
|
|
37
|
+
module?: string;
|
|
38
|
+
cellIndex?: number;
|
|
39
|
+
type: TypeNode;
|
|
40
|
+
sample?: unknown;
|
|
41
|
+
funcName?: string;
|
|
42
|
+
error?: string;
|
|
43
|
+
errorLine?: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function typeToString(node: TypeNode): string {
|
|
47
|
+
if (!node) return "unknown";
|
|
48
|
+
switch (node.kind) {
|
|
49
|
+
case "primitive": return node.name || "unknown";
|
|
50
|
+
case "object": {
|
|
51
|
+
if (!node.properties) return node.class_name || "object";
|
|
52
|
+
if (node.class_name === "Tensor" || node.class_name === "ndarray") {
|
|
53
|
+
const shape = node.properties["shape"]?.name;
|
|
54
|
+
const dtype = node.properties["dtype"]?.name;
|
|
55
|
+
const parts: string[] = [];
|
|
56
|
+
if (shape) parts.push(`shape=${shape}`);
|
|
57
|
+
if (dtype) parts.push(`dtype=${dtype}`);
|
|
58
|
+
return `${node.class_name}(${parts.join(", ")})`;
|
|
59
|
+
}
|
|
60
|
+
if (node.class_name === "DataFrame") {
|
|
61
|
+
const rows = node.properties["rows"]?.name;
|
|
62
|
+
const cols = node.properties["cols"]?.name;
|
|
63
|
+
const parts: string[] = [];
|
|
64
|
+
if (rows && cols) parts.push(`${rows}x${cols}`);
|
|
65
|
+
return `DataFrame(${parts.join(", ")})`;
|
|
66
|
+
}
|
|
67
|
+
if (node.class_name) {
|
|
68
|
+
const keys = Object.keys(node.properties).slice(0, 4);
|
|
69
|
+
const extra = Object.keys(node.properties).length > 4 ? `, +${Object.keys(node.properties).length - 4}` : "";
|
|
70
|
+
return `${node.class_name}(${keys.join(", ")}${extra})`;
|
|
71
|
+
}
|
|
72
|
+
const props = Object.entries(node.properties).slice(0, 5)
|
|
73
|
+
.map(([k, v]) => `${k}: ${typeToString(v)}`);
|
|
74
|
+
const extra = Object.keys(node.properties).length > 5 ? `, +${Object.keys(node.properties).length - 5}` : "";
|
|
75
|
+
return `{${props.join(", ")}${extra}}`;
|
|
76
|
+
}
|
|
77
|
+
case "array": {
|
|
78
|
+
const elem = node.element;
|
|
79
|
+
if (elem?.kind === "union") {
|
|
80
|
+
const members = elem.elements || elem.members;
|
|
81
|
+
if (members && members.length > 0) {
|
|
82
|
+
const names = new Set(members.map(m => m.class_name).filter(Boolean));
|
|
83
|
+
if (names.size === 1) return `${names.values().next().value}[]`;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return `${typeToString(elem || { kind: "primitive", name: "unknown" })}[]`;
|
|
87
|
+
}
|
|
88
|
+
case "tuple": return `[${(node.elements || []).map(typeToString).join(", ")}]`;
|
|
89
|
+
case "union": {
|
|
90
|
+
const members = node.elements || node.members;
|
|
91
|
+
if (!members) return "unknown";
|
|
92
|
+
const names = new Set(members.map(m => m.class_name).filter(Boolean));
|
|
93
|
+
if (names.size === 1) return names.values().next().value!;
|
|
94
|
+
return members.map(typeToString).join(" | ");
|
|
95
|
+
}
|
|
96
|
+
case "function": return "Function";
|
|
97
|
+
default: return node.kind;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function formatSample(sample: unknown): string {
|
|
102
|
+
if (sample === null || sample === undefined) return "";
|
|
103
|
+
if (typeof sample === "string") {
|
|
104
|
+
if (sample.length > 40) return `"${sample.substring(0, 37)}..."`;
|
|
105
|
+
return `"${sample}"`;
|
|
106
|
+
}
|
|
107
|
+
if (typeof sample === "number") {
|
|
108
|
+
return Number.isInteger(sample) ? String(sample) : sample.toFixed(4);
|
|
109
|
+
}
|
|
110
|
+
if (typeof sample === "boolean") return String(sample);
|
|
111
|
+
if (Array.isArray(sample)) return `[...${sample.length} items]`;
|
|
112
|
+
const s = String(sample);
|
|
113
|
+
return s.length > 40 ? s.substring(0, 37) + "..." : s;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function hintsCommand(
|
|
117
|
+
targetFile: string | undefined,
|
|
118
|
+
opts: HintsOptions,
|
|
119
|
+
): Promise<void> {
|
|
120
|
+
const localDir = process.env.TRICKLE_LOCAL_DIR || path.join(process.cwd(), ".trickle");
|
|
121
|
+
const varsFile = path.join(localDir, "variables.jsonl");
|
|
122
|
+
|
|
123
|
+
if (!fs.existsSync(varsFile)) {
|
|
124
|
+
console.error("No trickle data found. Run your app with trickle first:");
|
|
125
|
+
console.error(" trickle run python app.py");
|
|
126
|
+
console.error(" trickle run node app.js");
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Load observations from variables.jsonl
|
|
131
|
+
const obsMap = new Map<string, VarObservation>();
|
|
132
|
+
const errorSnaps: VarObservation[] = [];
|
|
133
|
+
const targetKinds = opts.errors ? ["error_snapshot"] : ["variable"];
|
|
134
|
+
|
|
135
|
+
for (const line of fs.readFileSync(varsFile, "utf-8").split("\n").filter(Boolean)) {
|
|
136
|
+
try {
|
|
137
|
+
const v = JSON.parse(line) as VarObservation;
|
|
138
|
+
if (v.kind === "variable") {
|
|
139
|
+
const key = `${v.file}:${v.line}:${v.varName}`;
|
|
140
|
+
obsMap.set(key, v);
|
|
141
|
+
}
|
|
142
|
+
if (v.kind === "error_snapshot") {
|
|
143
|
+
errorSnaps.push(v);
|
|
144
|
+
}
|
|
145
|
+
} catch {}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// In error mode, use error snapshots; look up original assignment lines from regular vars
|
|
149
|
+
let vars: VarObservation[];
|
|
150
|
+
if (opts.errors) {
|
|
151
|
+
if (errorSnaps.length === 0) {
|
|
152
|
+
console.error("No error snapshots found. Run code that produces an error first.");
|
|
153
|
+
process.exit(1);
|
|
154
|
+
}
|
|
155
|
+
vars = errorSnaps;
|
|
156
|
+
} else {
|
|
157
|
+
vars = [...obsMap.values()];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Filter by target file
|
|
161
|
+
let filtered = vars;
|
|
162
|
+
if (targetFile) {
|
|
163
|
+
const normalized = targetFile.replace(/^\.\//, "");
|
|
164
|
+
filtered = vars.filter(v => {
|
|
165
|
+
const relPath = path.relative(process.cwd(), v.file);
|
|
166
|
+
return relPath.includes(normalized) || v.file.includes(normalized);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (filtered.length === 0) {
|
|
171
|
+
console.error(targetFile ? `No observations found for "${targetFile}".` : "No observations found.");
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// In error mode, resolve each snapshot var to its original assignment line
|
|
176
|
+
// by looking up the regular variable observations
|
|
177
|
+
if (opts.errors) {
|
|
178
|
+
for (const snap of filtered) {
|
|
179
|
+
// Find matching regular observation to get original line
|
|
180
|
+
for (const [, obs] of obsMap) {
|
|
181
|
+
if (obs.file === snap.file && obs.varName === snap.varName) {
|
|
182
|
+
snap.line = obs.line; // use original assignment line
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Group by file
|
|
190
|
+
const byFile = new Map<string, VarObservation[]>();
|
|
191
|
+
for (const v of filtered) {
|
|
192
|
+
if (!byFile.has(v.file)) byFile.set(v.file, []);
|
|
193
|
+
byFile.get(v.file)!.push(v);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
for (const [absFile, fileVars] of byFile) {
|
|
197
|
+
let sourceLines: string[] = [];
|
|
198
|
+
let relPath: string = "";
|
|
199
|
+
|
|
200
|
+
// Handle notebook cell paths: __notebook__cell_N.py
|
|
201
|
+
const cellMatch = absFile.match(/__notebook__cell_(\d+)\.py$/);
|
|
202
|
+
if (cellMatch) {
|
|
203
|
+
// Try to find the notebook .ipynb in the same directory
|
|
204
|
+
const dir = path.dirname(absFile);
|
|
205
|
+
const cellIdx = parseInt(cellMatch[1]);
|
|
206
|
+
const ipynbFiles = fs.existsSync(dir)
|
|
207
|
+
? fs.readdirSync(dir).filter(f => f.endsWith(".ipynb"))
|
|
208
|
+
: [];
|
|
209
|
+
let foundCell = false;
|
|
210
|
+
for (const nbFile of ipynbFiles) {
|
|
211
|
+
const nbPath = path.join(dir, nbFile);
|
|
212
|
+
try {
|
|
213
|
+
const nb = JSON.parse(fs.readFileSync(nbPath, "utf-8"));
|
|
214
|
+
const cells = nb.cells || [];
|
|
215
|
+
// trickle's cellIdx is the Nth code cell execution (1-based).
|
|
216
|
+
// Try direct index first, then count code cells.
|
|
217
|
+
let cellSource: string[] | undefined;
|
|
218
|
+
// Try: cellIdx maps to 0-based index in all cells
|
|
219
|
+
if (cells[cellIdx - 1]?.cell_type === "code") {
|
|
220
|
+
cellSource = cells[cellIdx - 1].source;
|
|
221
|
+
}
|
|
222
|
+
// Fallback: count code cells
|
|
223
|
+
if (!cellSource) {
|
|
224
|
+
let codeCount = 0;
|
|
225
|
+
for (const c of cells) {
|
|
226
|
+
if (c.cell_type === "code") {
|
|
227
|
+
codeCount++;
|
|
228
|
+
if (codeCount === cellIdx) {
|
|
229
|
+
cellSource = c.source;
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (!cellSource) continue;
|
|
236
|
+
sourceLines = (Array.isArray(cellSource) ? cellSource.join("") : String(cellSource)).split("\n");
|
|
237
|
+
relPath = `${path.relative(process.cwd(), nbPath)} [cell ${cellIdx}]`;
|
|
238
|
+
foundCell = true;
|
|
239
|
+
break;
|
|
240
|
+
} catch {
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (!foundCell) {
|
|
245
|
+
// Can't find source — output just the observations in a summary format
|
|
246
|
+
relPath = absFile.replace(/.*__notebook__/, "notebook ").replace(/\.py$/, "");
|
|
247
|
+
const maxLine = Math.max(...fileVars.map(v => v.line));
|
|
248
|
+
sourceLines = Array.from({ length: maxLine }, (_, i) => `# line ${i + 1}`);
|
|
249
|
+
// Place observations as standalone lines
|
|
250
|
+
const lineObs = new Map<number, Map<string, VarObservation>>();
|
|
251
|
+
for (const v of fileVars) {
|
|
252
|
+
if (!lineObs.has(v.line)) lineObs.set(v.line, new Map());
|
|
253
|
+
lineObs.get(v.line)!.set(v.varName, v);
|
|
254
|
+
}
|
|
255
|
+
// Print header with error info if in error mode
|
|
256
|
+
const errorMsg = fileVars.find(v => v.error)?.error;
|
|
257
|
+
const errorLine = fileVars.find(v => v.errorLine)?.errorLine;
|
|
258
|
+
if (opts.errors && errorMsg) {
|
|
259
|
+
console.log(`# ${relPath} — ERROR`);
|
|
260
|
+
console.log(`# ${errorMsg}${errorLine ? ` (line ${errorLine})` : ""}`);
|
|
261
|
+
console.log(`# Variables at crash time:`);
|
|
262
|
+
} else {
|
|
263
|
+
console.log(`# ${relPath}`);
|
|
264
|
+
}
|
|
265
|
+
console.log("```python");
|
|
266
|
+
for (const [lineNo, obs] of [...lineObs.entries()].sort((a, b) => a[0] - b[0])) {
|
|
267
|
+
for (const v of obs.values()) {
|
|
268
|
+
const typeStr = typeToString(v.type);
|
|
269
|
+
const scope = v.funcName ? ` (in ${v.funcName})` : "";
|
|
270
|
+
const sampleStr = formatSample(v.sample);
|
|
271
|
+
// In error mode, always show values (crash-time state)
|
|
272
|
+
if ((opts.errors || opts.values) && v.sample !== undefined) {
|
|
273
|
+
console.log(`${v.varName}: ${typeStr} = ${sampleStr}${scope}`);
|
|
274
|
+
} else {
|
|
275
|
+
console.log(`${v.varName}: ${typeStr}${scope}`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
console.log("```");
|
|
280
|
+
console.log("");
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
} else {
|
|
284
|
+
if (!fs.existsSync(absFile)) continue;
|
|
285
|
+
sourceLines = fs.readFileSync(absFile, "utf-8").split("\n");
|
|
286
|
+
relPath = path.relative(process.cwd(), absFile);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Build line → varName → observation map
|
|
290
|
+
const lineObs = new Map<number, Map<string, VarObservation>>();
|
|
291
|
+
for (const v of fileVars) {
|
|
292
|
+
if (!lineObs.has(v.line)) lineObs.set(v.line, new Map());
|
|
293
|
+
lineObs.get(v.line)!.set(v.varName, v);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Print header with error info if in error mode
|
|
297
|
+
const errMsg = fileVars.find(v => v.error)?.error;
|
|
298
|
+
const errLine = fileVars.find(v => v.errorLine)?.errorLine;
|
|
299
|
+
if (opts.errors && errMsg) {
|
|
300
|
+
console.log(`# ${relPath} — ERROR`);
|
|
301
|
+
console.log(`# ${errMsg}${errLine ? ` (line ${errLine})` : ""}`);
|
|
302
|
+
console.log(`# Variables at crash time:`);
|
|
303
|
+
} else {
|
|
304
|
+
console.log(`# ${relPath}`);
|
|
305
|
+
}
|
|
306
|
+
console.log("```python");
|
|
307
|
+
|
|
308
|
+
for (let i = 0; i < sourceLines.length; i++) {
|
|
309
|
+
const lineNo = i + 1;
|
|
310
|
+
const src = sourceLines[i];
|
|
311
|
+
const obs = lineObs.get(lineNo);
|
|
312
|
+
|
|
313
|
+
if (!obs || obs.size === 0) {
|
|
314
|
+
console.log(src);
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Insert type hints inline after variable names
|
|
319
|
+
let annotated = src;
|
|
320
|
+
// Sort by position in line (rightmost first so indices don't shift)
|
|
321
|
+
const entries = [...obs.values()].sort((a, b) => {
|
|
322
|
+
const aIdx = src.indexOf(a.varName);
|
|
323
|
+
const bIdx = src.indexOf(b.varName);
|
|
324
|
+
return bIdx - aIdx; // rightmost first
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
for (const v of entries) {
|
|
328
|
+
const typeStr = typeToString(v.type);
|
|
329
|
+
// Skip if the type is just "unknown" or not useful
|
|
330
|
+
if (typeStr === "unknown") continue;
|
|
331
|
+
|
|
332
|
+
// Find variable in the line
|
|
333
|
+
const isAttr = v.varName.includes(".");
|
|
334
|
+
const pattern = isAttr
|
|
335
|
+
? new RegExp(v.varName.replace(/\./g, "\\."))
|
|
336
|
+
: new RegExp(`\\b${v.varName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
|
|
337
|
+
const match = pattern.exec(annotated);
|
|
338
|
+
if (!match) continue;
|
|
339
|
+
|
|
340
|
+
const varEnd = match.index + v.varName.length;
|
|
341
|
+
const afterVar = annotated.substring(varEnd).trimStart();
|
|
342
|
+
|
|
343
|
+
// Check this is an assignment/declaration context
|
|
344
|
+
const beforeVar = annotated.substring(0, match.index);
|
|
345
|
+
const isPython = absFile.endsWith(".py");
|
|
346
|
+
if (isPython) {
|
|
347
|
+
const isAssignment = afterVar.startsWith("=") && !afterVar.startsWith("==");
|
|
348
|
+
const isAnnotated = afterVar.startsWith(":");
|
|
349
|
+
const isForVar = /\bfor\s+$/.test(beforeVar) || /\bfor\s+.*,\s*$/.test(beforeVar);
|
|
350
|
+
const isWithAs = /\bas\s+$/.test(beforeVar);
|
|
351
|
+
const isFuncParam = /\b(?:async\s+)?def\s+\w+\s*\(/.test(beforeVar) &&
|
|
352
|
+
(afterVar.startsWith(",") || afterVar.startsWith(")") || afterVar.startsWith("=") || afterVar.startsWith(":"));
|
|
353
|
+
const isBareAssign = /^\s*$/.test(beforeVar) || /,\s*$/.test(beforeVar);
|
|
354
|
+
|
|
355
|
+
if (!isForVar && !isWithAs && !isFuncParam && !((isBareAssign) && (isAssignment || isAnnotated))) continue;
|
|
356
|
+
if (isAnnotated && !isFuncParam) continue;
|
|
357
|
+
if (isFuncParam && afterVar.startsWith(":")) continue;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Build the hint string — error mode always shows values (crash-time state)
|
|
361
|
+
let hint: string;
|
|
362
|
+
if ((opts.errors || opts.values) && v.sample !== undefined && v.sample !== null) {
|
|
363
|
+
const sampleStr = formatSample(v.sample);
|
|
364
|
+
hint = sampleStr ? `: ${typeStr} = ${sampleStr}` : `: ${typeStr}`;
|
|
365
|
+
} else {
|
|
366
|
+
hint = `: ${typeStr}`;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Insert after variable name
|
|
370
|
+
annotated = annotated.substring(0, varEnd) + hint + annotated.substring(varEnd);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
console.log(annotated);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
console.log("```");
|
|
377
|
+
console.log("");
|
|
378
|
+
}
|
|
379
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -44,6 +44,7 @@ import { mcpServerCommand } from "./commands/mcp-server";
|
|
|
44
44
|
import { rnCommand } from "./commands/rn";
|
|
45
45
|
import { nextCommand } from "./commands/next";
|
|
46
46
|
import { pythonCommand } from "./commands/python";
|
|
47
|
+
import { hintsCommand } from "./commands/hints";
|
|
47
48
|
|
|
48
49
|
const program = new Command();
|
|
49
50
|
|
|
@@ -534,6 +535,16 @@ program
|
|
|
534
535
|
await varsCommand(opts);
|
|
535
536
|
});
|
|
536
537
|
|
|
538
|
+
// trickle hints
|
|
539
|
+
program
|
|
540
|
+
.command("hints [file]")
|
|
541
|
+
.description("Output source code with inline type hints from runtime observations (for AI agents)")
|
|
542
|
+
.option("--values", "Include sample values alongside types")
|
|
543
|
+
.option("--errors", "Show error mode — variables at crash time with values that caused the error")
|
|
544
|
+
.action(async (file: string | undefined, opts: { values?: boolean; errors?: boolean }) => {
|
|
545
|
+
await hintsCommand(file, opts);
|
|
546
|
+
});
|
|
547
|
+
|
|
537
548
|
// trickle layers
|
|
538
549
|
program
|
|
539
550
|
.command("layers")
|