invar-tools 1.14.0__py3-none-any.whl → 1.15.1__py3-none-any.whl

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.
@@ -0,0 +1,596 @@
1
+ /**
2
+ * Invar Custom Tools for Pi Coding Agent
3
+ *
4
+ * Wraps Invar CLI commands as Pi tools for better LLM integration.
5
+ * Installed via: invar init --pi
6
+ */
7
+
8
+ import { Type } from "@sinclair/typebox";
9
+ import type { CustomToolFactory } from "@mariozechner/pi-coding-agent";
10
+
11
+ const factory: CustomToolFactory = (pi) => {
12
+ // Helper to resolve invar command (with uvx fallback)
13
+ async function resolveInvarCommand(): Promise<{ command: string; args: string[] }> {
14
+ // Try direct invar command first
15
+ try {
16
+ const result = await pi.exec("which", ["invar"]);
17
+ if (result.exitCode === 0) {
18
+ return { command: "invar", args: [] };
19
+ }
20
+ } catch {
21
+ // Fall through to uvx
22
+ }
23
+
24
+ // Fallback to uvx invar-tools
25
+ return { command: "uvx", args: ["invar-tools"] };
26
+ }
27
+
28
+ // Helper to validate path/target parameters (defense-in-depth)
29
+ function isValidPath(p: string): boolean {
30
+ // Reject shell metacharacters (including newline injection) and path traversal
31
+ if (/[;&|`$"'\\<>\n\r\0]/.test(p)) {
32
+ return false;
33
+ }
34
+ if (p.includes('..')) {
35
+ return false;
36
+ }
37
+ return true;
38
+ }
39
+
40
+ return [
41
+ // =========================================================================
42
+ // invar_guard - Smart verification (static + doctests + symbolic)
43
+ // =========================================================================
44
+ {
45
+ name: "invar_guard",
46
+ label: "Invar Guard",
47
+ description: "Verify code quality with static analysis, doctests, CrossHair symbolic execution, and Hypothesis testing. Use this instead of pytest/crosshair. By default checks git-modified files; use --all for full project check.",
48
+ parameters: Type.Object({
49
+ changed: Type.Optional(Type.Boolean({
50
+ description: "Check only git-modified files (default: true)",
51
+ default: true,
52
+ })),
53
+ contracts_only: Type.Optional(Type.Boolean({
54
+ description: "Contract coverage check only (skip tests)",
55
+ default: false,
56
+ })),
57
+ coverage: Type.Optional(Type.Boolean({
58
+ description: "Collect branch coverage from doctest + hypothesis",
59
+ default: false,
60
+ })),
61
+ strict: Type.Optional(Type.Boolean({
62
+ description: "Treat warnings as errors",
63
+ default: false,
64
+ })),
65
+ }),
66
+ async execute(toolCallId, params, onUpdate, ctx, signal) {
67
+ const cmd = await resolveInvarCommand();
68
+ const args = [...cmd.args, "guard"];
69
+
70
+ // Default is --changed (check modified files)
71
+ if (params.changed === false) {
72
+ args.push("--all");
73
+ }
74
+
75
+ if (params.contracts_only) {
76
+ args.push("-c");
77
+ }
78
+ if (params.coverage) {
79
+ args.push("--coverage");
80
+ }
81
+ if (params.strict) {
82
+ args.push("--strict");
83
+ }
84
+
85
+ const result = await pi.exec(cmd.command, args, { cwd: pi.cwd, signal });
86
+
87
+ if (result.killed) {
88
+ throw new Error("Guard verification was cancelled");
89
+ }
90
+
91
+ const output = result.stdout + result.stderr;
92
+
93
+ return {
94
+ content: [{ type: "text", text: output || "Guard completed" }],
95
+ details: {
96
+ exitCode: result.exitCode,
97
+ passed: result.exitCode === 0,
98
+ },
99
+ };
100
+ },
101
+ },
102
+
103
+ // =========================================================================
104
+ // invar_sig - Show function signatures and contracts
105
+ // =========================================================================
106
+ {
107
+ name: "invar_sig",
108
+ label: "Invar Sig",
109
+ description: "Show function signatures and contracts (@pre/@post). Use this INSTEAD of Read() when you want to understand file structure without reading full implementation.",
110
+ parameters: Type.Object({
111
+ target: Type.String({
112
+ description: "File path or file::symbol path (e.g., 'src/foo.py' or 'src/foo.py::MyClass')",
113
+ }),
114
+ }),
115
+ async execute(toolCallId, params, onUpdate, ctx, signal) {
116
+ const cmd = await resolveInvarCommand();
117
+
118
+ if (!isValidPath(params.target)) {
119
+ throw new Error("Invalid target path: contains unsafe characters or path traversal");
120
+ }
121
+
122
+ const result = await pi.exec(cmd.command, [...cmd.args, "sig", params.target], {
123
+ cwd: pi.cwd,
124
+ signal,
125
+ });
126
+
127
+ if (result.killed) {
128
+ throw new Error("Sig command was cancelled");
129
+ }
130
+
131
+ if (result.exitCode !== 0) {
132
+ throw new Error(`Failed to get signatures: ${result.stderr}`);
133
+ }
134
+
135
+ return {
136
+ content: [{ type: "text", text: result.stdout }],
137
+ details: {
138
+ target: params.target,
139
+ },
140
+ };
141
+ },
142
+ },
143
+
144
+ // =========================================================================
145
+ // invar_map - Symbol map with reference counts
146
+ // =========================================================================
147
+ {
148
+ name: "invar_map",
149
+ label: "Invar Map",
150
+ description: "Symbol map with reference counts. Use this INSTEAD of Grep for 'def ' to find entry points and most-referenced symbols.",
151
+ parameters: Type.Object({
152
+ path: Type.Optional(Type.String({
153
+ description: "Project path (default: current directory)",
154
+ default: ".",
155
+ })),
156
+ top: Type.Optional(Type.Number({
157
+ description: "Show top N symbols by reference count",
158
+ default: 10,
159
+ })),
160
+ }),
161
+ async execute(toolCallId, params, onUpdate, ctx, signal) {
162
+ const cmd = await resolveInvarCommand();
163
+
164
+ if (params.path && params.path !== "." && !isValidPath(params.path)) {
165
+ throw new Error("Invalid path: contains unsafe characters or path traversal");
166
+ }
167
+
168
+ const args = [...cmd.args, "map"];
169
+
170
+ if (params.path && params.path !== ".") {
171
+ args.push(params.path);
172
+ }
173
+
174
+ if (params.top) {
175
+ args.push("--top", params.top.toString());
176
+ }
177
+
178
+ const result = await pi.exec(cmd.command, args, {
179
+ cwd: pi.cwd,
180
+ signal,
181
+ });
182
+
183
+ if (result.killed) {
184
+ throw new Error("Map command was cancelled");
185
+ }
186
+
187
+ if (result.exitCode !== 0) {
188
+ throw new Error(`Failed to generate map: ${result.stderr}`);
189
+ }
190
+
191
+ return {
192
+ content: [{ type: "text", text: result.stdout }],
193
+ details: {
194
+ path: params.path || ".",
195
+ top: params.top || 10,
196
+ },
197
+ };
198
+ },
199
+ },
200
+
201
+ // =========================================================================
202
+ // invar_doc_toc - Extract document structure (Table of Contents)
203
+ // =========================================================================
204
+ {
205
+ name: "invar_doc_toc",
206
+ label: "Invar Doc TOC",
207
+ description: "Extract document structure (Table of Contents) from markdown files. Shows headings hierarchy with line numbers and character counts. Use this INSTEAD of Read() to understand markdown structure.",
208
+ parameters: Type.Object({
209
+ file: Type.String({
210
+ description: "Path to markdown file",
211
+ }),
212
+ depth: Type.Optional(Type.Number({
213
+ description: "Maximum heading depth to include (1-6)",
214
+ default: 6,
215
+ minimum: 1,
216
+ maximum: 6,
217
+ })),
218
+ }),
219
+ async execute(toolCallId, params, onUpdate, ctx, signal) {
220
+ const cmd = await resolveInvarCommand();
221
+
222
+ if (!isValidPath(params.file)) {
223
+ throw new Error("Invalid file path: contains unsafe characters or path traversal");
224
+ }
225
+
226
+ const args = [...cmd.args, "doc", "toc", params.file];
227
+
228
+ if (params.depth && params.depth !== 6) {
229
+ args.push("--depth", params.depth.toString());
230
+ }
231
+
232
+ const result = await pi.exec(cmd.command, args, {
233
+ cwd: pi.cwd,
234
+ signal,
235
+ });
236
+
237
+ if (result.killed) {
238
+ throw new Error("Doc toc command was cancelled");
239
+ }
240
+
241
+ if (result.exitCode !== 0) {
242
+ throw new Error(`Failed to extract TOC: ${result.stderr}`);
243
+ }
244
+
245
+ return {
246
+ content: [{ type: "text", text: result.stdout }],
247
+ details: {
248
+ file: params.file,
249
+ depth: params.depth || 6,
250
+ },
251
+ };
252
+ },
253
+ },
254
+
255
+ // =========================================================================
256
+ // invar_doc_read - Read a specific section from a document
257
+ // =========================================================================
258
+ {
259
+ name: "invar_doc_read",
260
+ label: "Invar Doc Read",
261
+ description: "Read a specific section from a markdown document. Supports multiple addressing formats: slug path, fuzzy match, index (#0/#1), or line anchor (@48). Use this INSTEAD of Read() with manual line counting.",
262
+ parameters: Type.Object({
263
+ file: Type.String({
264
+ description: "Path to markdown file",
265
+ }),
266
+ section: Type.String({
267
+ description: "Section path: slug ('requirements/auth'), fuzzy ('auth'), index ('#0/#1'), or line ('@48')",
268
+ }),
269
+ children: Type.Optional(Type.Boolean({
270
+ description: "Include child sections in output",
271
+ default: true,
272
+ })),
273
+ }),
274
+ async execute(toolCallId, params, onUpdate, ctx, signal) {
275
+ const cmd = await resolveInvarCommand();
276
+
277
+ if (!isValidPath(params.file) || !isValidPath(params.section)) {
278
+ throw new Error("Invalid file or section path: contains unsafe characters or path traversal");
279
+ }
280
+
281
+ const args = [...cmd.args, "doc", "read", params.file, params.section, "--json"];
282
+
283
+ if (params.children === false) {
284
+ args.push("--no-children");
285
+ }
286
+
287
+ const result = await pi.exec(cmd.command, args, {
288
+ cwd: pi.cwd,
289
+ signal,
290
+ });
291
+
292
+ if (result.killed) {
293
+ throw new Error("Doc read command was cancelled");
294
+ }
295
+
296
+ if (result.exitCode !== 0) {
297
+ throw new Error(`Failed to read section: ${result.stderr}`);
298
+ }
299
+
300
+ return {
301
+ content: [{ type: "text", text: result.stdout }],
302
+ details: {
303
+ file: params.file,
304
+ section: params.section,
305
+ },
306
+ };
307
+ },
308
+ },
309
+
310
+ // =========================================================================
311
+ // invar_doc_find - Find sections matching a pattern
312
+ // =========================================================================
313
+ {
314
+ name: "invar_doc_find",
315
+ label: "Invar Doc Find",
316
+ description: "Find sections in markdown documents matching a pattern. Supports glob patterns for titles and optional content search. Use this INSTEAD of Grep in markdown files.",
317
+ parameters: Type.Object({
318
+ file: Type.String({
319
+ description: "Path to markdown file",
320
+ }),
321
+ pattern: Type.String({
322
+ description: "Title pattern (glob-style, e.g., '*auth*')",
323
+ }),
324
+ content: Type.Optional(Type.String({
325
+ description: "Optional content search pattern",
326
+ })),
327
+ level: Type.Optional(Type.Number({
328
+ description: "Filter by heading level (1-6)",
329
+ minimum: 1,
330
+ maximum: 6,
331
+ })),
332
+ }),
333
+ async execute(toolCallId, params, onUpdate, ctx, signal) {
334
+ const cmd = await resolveInvarCommand();
335
+
336
+ if (!isValidPath(params.file)) {
337
+ throw new Error("Invalid file path: contains unsafe characters or path traversal");
338
+ }
339
+
340
+ const args = [...cmd.args, "doc", "find", params.pattern, params.file, "--json"];
341
+
342
+ if (params.content) {
343
+ args.push("--content", params.content);
344
+ }
345
+
346
+ if (params.level) {
347
+ args.push("--level", params.level.toString());
348
+ }
349
+
350
+ const result = await pi.exec(cmd.command, args, {
351
+ cwd: pi.cwd,
352
+ signal,
353
+ });
354
+
355
+ if (result.killed) {
356
+ throw new Error("Doc find command was cancelled");
357
+ }
358
+
359
+ if (result.exitCode !== 0) {
360
+ throw new Error(`Failed to find sections: ${result.stderr}`);
361
+ }
362
+
363
+ return {
364
+ content: [{ type: "text", text: result.stdout }],
365
+ details: {
366
+ file: params.file,
367
+ pattern: params.pattern,
368
+ },
369
+ };
370
+ },
371
+ },
372
+
373
+ // =========================================================================
374
+ // invar_doc_replace - Replace a section's content
375
+ // =========================================================================
376
+ {
377
+ name: "invar_doc_replace",
378
+ label: "Invar Doc Replace",
379
+ description: "Replace a section's content in a markdown document. Use this INSTEAD of Edit()/Write() for section replacement.",
380
+ parameters: Type.Object({
381
+ file: Type.String({
382
+ description: "Path to markdown file",
383
+ }),
384
+ section: Type.String({
385
+ description: "Section path to replace (slug, fuzzy, index, or line anchor)",
386
+ }),
387
+ content: Type.String({
388
+ description: "New content to replace the section with",
389
+ }),
390
+ keep_heading: Type.Optional(Type.Boolean({
391
+ description: "If true, preserve the original heading line",
392
+ default: true,
393
+ })),
394
+ }),
395
+ async execute(toolCallId, params, onUpdate, ctx, signal) {
396
+ const cmd = await resolveInvarCommand();
397
+
398
+ if (!isValidPath(params.file) || !isValidPath(params.section)) {
399
+ throw new Error("Invalid file or section path: contains unsafe characters or path traversal");
400
+ }
401
+
402
+ // Write content to temporary file to avoid shell injection
403
+ const fs = require("fs");
404
+ const path = require("path");
405
+ const tmpFile = path.join(pi.cwd, `.invar-tmp-${Date.now()}.txt`);
406
+
407
+ try {
408
+ fs.writeFileSync(tmpFile, params.content, "utf-8");
409
+
410
+ const args = [
411
+ ...cmd.args,
412
+ "doc",
413
+ "replace",
414
+ params.file,
415
+ params.section,
416
+ "--content",
417
+ tmpFile,
418
+ ];
419
+
420
+ if (params.keep_heading === false) {
421
+ args.push("--no-keep-heading");
422
+ }
423
+
424
+ const result = await pi.exec(cmd.command, args, {
425
+ cwd: pi.cwd,
426
+ signal,
427
+ });
428
+
429
+ if (result.killed) {
430
+ throw new Error("Doc replace command was cancelled");
431
+ }
432
+
433
+ if (result.exitCode !== 0) {
434
+ throw new Error(`Failed to replace section: ${result.stderr}`);
435
+ }
436
+
437
+ return {
438
+ content: [{ type: "text", text: result.stdout || "Section replaced successfully" }],
439
+ details: {
440
+ file: params.file,
441
+ section: params.section,
442
+ },
443
+ };
444
+ } finally {
445
+ // Clean up temp file
446
+ try {
447
+ fs.unlinkSync(tmpFile);
448
+ } catch {
449
+ // Ignore cleanup errors
450
+ }
451
+ }
452
+ },
453
+ },
454
+
455
+ // =========================================================================
456
+ // invar_doc_insert - Insert new content relative to a section
457
+ // =========================================================================
458
+ {
459
+ name: "invar_doc_insert",
460
+ label: "Invar Doc Insert",
461
+ description: "Insert new content relative to a section in a markdown document. Use this INSTEAD of Edit()/Write() for section insertion.",
462
+ parameters: Type.Object({
463
+ file: Type.String({
464
+ description: "Path to markdown file",
465
+ }),
466
+ anchor: Type.String({
467
+ description: "Section path for the anchor (slug, fuzzy, index, or line anchor)",
468
+ }),
469
+ content: Type.String({
470
+ description: "Content to insert (include heading if new section)",
471
+ }),
472
+ position: Type.Optional(Type.String({
473
+ description: "Where to insert: 'before', 'after', 'first_child', 'last_child'",
474
+ default: "after",
475
+ enum: ["before", "after", "first_child", "last_child"],
476
+ })),
477
+ }),
478
+ async execute(toolCallId, params, onUpdate, ctx, signal) {
479
+ const cmd = await resolveInvarCommand();
480
+
481
+ if (!isValidPath(params.file) || !isValidPath(params.anchor)) {
482
+ throw new Error("Invalid file or anchor path: contains unsafe characters or path traversal");
483
+ }
484
+
485
+ // Write content to temporary file
486
+ const fs = require("fs");
487
+ const path = require("path");
488
+ const tmpFile = path.join(pi.cwd, `.invar-tmp-${Date.now()}.txt`);
489
+
490
+ try {
491
+ fs.writeFileSync(tmpFile, params.content, "utf-8");
492
+
493
+ const args = [
494
+ ...cmd.args,
495
+ "doc",
496
+ "insert",
497
+ params.file,
498
+ params.anchor,
499
+ "--content",
500
+ tmpFile,
501
+ ];
502
+
503
+ if (params.position && params.position !== "after") {
504
+ args.push("--position", params.position);
505
+ }
506
+
507
+ const result = await pi.exec(cmd.command, args, {
508
+ cwd: pi.cwd,
509
+ signal,
510
+ });
511
+
512
+ if (result.killed) {
513
+ throw new Error("Doc insert command was cancelled");
514
+ }
515
+
516
+ if (result.exitCode !== 0) {
517
+ throw new Error(`Failed to insert content: ${result.stderr}`);
518
+ }
519
+
520
+ return {
521
+ content: [{ type: "text", text: result.stdout || "Content inserted successfully" }],
522
+ details: {
523
+ file: params.file,
524
+ anchor: params.anchor,
525
+ position: params.position || "after",
526
+ },
527
+ };
528
+ } finally {
529
+ // Clean up temp file
530
+ try {
531
+ fs.unlinkSync(tmpFile);
532
+ } catch {
533
+ // Ignore cleanup errors
534
+ }
535
+ }
536
+ },
537
+ },
538
+
539
+ // =========================================================================
540
+ // invar_doc_delete - Delete a section from a document
541
+ // =========================================================================
542
+ {
543
+ name: "invar_doc_delete",
544
+ label: "Invar Doc Delete",
545
+ description: "Delete a section from a markdown document. Use this INSTEAD of Edit()/Write() for section deletion.",
546
+ parameters: Type.Object({
547
+ file: Type.String({
548
+ description: "Path to markdown file",
549
+ }),
550
+ section: Type.String({
551
+ description: "Section path to delete (slug, fuzzy, index, or line anchor)",
552
+ }),
553
+ children: Type.Optional(Type.Boolean({
554
+ description: "Include child sections in deletion",
555
+ default: true,
556
+ })),
557
+ }),
558
+ async execute(toolCallId, params, onUpdate, ctx, signal) {
559
+ const cmd = await resolveInvarCommand();
560
+
561
+ if (!isValidPath(params.file) || !isValidPath(params.section)) {
562
+ throw new Error("Invalid file or section path: contains unsafe characters or path traversal");
563
+ }
564
+
565
+ const args = [...cmd.args, "doc", "delete", params.file, params.section];
566
+
567
+ if (params.children === false) {
568
+ args.push("--no-children");
569
+ }
570
+
571
+ const result = await pi.exec(cmd.command, args, {
572
+ cwd: pi.cwd,
573
+ signal,
574
+ });
575
+
576
+ if (result.killed) {
577
+ throw new Error("Doc delete command was cancelled");
578
+ }
579
+
580
+ if (result.exitCode !== 0) {
581
+ throw new Error(`Failed to delete section: ${result.stderr}`);
582
+ }
583
+
584
+ return {
585
+ content: [{ type: "text", text: result.stdout || "Section deleted successfully" }],
586
+ details: {
587
+ file: params.file,
588
+ section: params.section,
589
+ },
590
+ };
591
+ },
592
+ },
593
+ ];
594
+ };
595
+
596
+ export default factory;