@dieulc/workflow 0.5.1 → 0.6.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/permissions.ts CHANGED
@@ -424,8 +424,14 @@ const DESTRUCTIVE_PATTERNS: RegExp[] = [
424
424
  /(^|\s)(sh|bash|dash|zsh|fish|ksh|csh|tcsh|pwsh|powershell|cmd|cscript|wscript|perl|ruby)(\.exe)?(\s|$)/i,
425
425
  ];
426
426
 
427
- /** Writing/formatting flags that must never reach a verify-class command. */
428
- const DENIED_FLAG_RE =
427
+ /**
428
+ * Writing/formatting flags that must never reach a verify-class command.
429
+ *
430
+ * Exported for the write policy's command classification (edit-policy.ts):
431
+ * a verify-class command that carries one of these still rewrites files
432
+ * (`eslint --fix`), so the write gate must treat it as a write.
433
+ */
434
+ export const DENIED_FLAG_RE =
429
435
  /(^|\s)(--fix|--fix-dry-run|--write|--update|--update-snapshots?|--updateSnapshot|--test-update-snapshots|--watch|--watchAll|--inspect|--inspect-brk|--in-place|--output)(\s|$|=)/i;
430
436
 
431
437
  /**
@@ -547,6 +553,22 @@ function packageScriptArg(segment: string): string | undefined {
547
553
  const NULL_DEVICE_REDIRECT_RE =
548
554
  /(^|[\s;&|()])(?:[12]|&)?>>?\s*\/dev\/null(?=$|[\s;&|)])/g;
549
555
 
556
+ /**
557
+ * Strip harmless redirection tokens from a command: fd duplications
558
+ * (`2>&1` / `1>&2`) and null-device targets (`> /dev/null`). Neither writes
559
+ * a file the user can see, so both are removed before the command is
560
+ * classified by its first word — otherwise `2>/dev/null ls` would look like
561
+ * an unknown binary and be treated as a write.
562
+ *
563
+ * Exported for the write policy's command classification (edit-policy.ts).
564
+ */
565
+ export function stripHarmlessRedirects(command: unknown): string {
566
+ const raw = typeof command === "string" ? command : "";
567
+ return raw
568
+ .replace(/\d*>&\d+/g, " ")
569
+ .replace(NULL_DEVICE_REDIRECT_RE, "$1");
570
+ }
571
+
550
572
  /**
551
573
  * Why a command's syntax cannot be classified (`null` = the syntax is fine).
552
574
  * Mirrors the order the gate has always used, so a refusal can name the first
@@ -556,9 +578,7 @@ export function forbiddenSyntaxReason(command: unknown): string | null {
556
578
  const raw = typeof command === "string" ? command : "";
557
579
  // `2>&1` / `1>&2` are fd duplications, not file writes; `/dev/null` targets
558
580
  // are a bit bucket, not a file.
559
- const scrubbed = raw
560
- .replace(/\d*>&\d+/g, " ")
561
- .replace(NULL_DEVICE_REDIRECT_RE, "$1");
581
+ const scrubbed = stripHarmlessRedirects(raw);
562
582
  if (scrubbed.replace(/&&/g, " ").includes("&"))
563
583
  return "a background `&` (only `&&` chains are allowed)";
564
584
  if (raw.includes("$(")) return "command substitution `$(...)`";
package/profiles.ts CHANGED
@@ -40,6 +40,8 @@ import {
40
40
  roleIcon,
41
41
  } from "./roles.ts";
42
42
  import { COMMIT_CONFIG_DIR, WORKFLOW_SETTINGS_KEY } from "./commit-policy.ts";
43
+ import type { ArgumentCompletion } from "./utils.ts";
44
+ import { joinArgumentValue, suppressExactArgumentMatch } from "./utils.ts";
43
45
 
44
46
  // ── Types ────────────────────────────────────────────────────────────
45
47
 
@@ -66,7 +68,7 @@ export const PROFILE_CONFIG_VERSION = 1;
66
68
  export const MAX_PROFILE_NAME_LENGTH = 40;
67
69
 
68
70
  /** Reserved words — they collide with `/profile use <name|none>` and `/role use` style keywords. */
69
- export const RESERVED_PROFILE_NAMES: readonly string[] = ["none", "auto"];
71
+ export const RESERVED_PROFILE_NAMES: readonly string[] = ["none", "auto", "off"];
70
72
 
71
73
  /** Fallback thinking level when a sanitized model omits an invalid one. */
72
74
  const DEFAULT_THINKING: ThinkingLevel = "medium";
@@ -615,3 +617,208 @@ export function writeDefaultProfileSetting(
615
617
  };
616
618
  }
617
619
  }
620
+
621
+ // ── Argument completions (/profile) ──────────────────────────────────
622
+
623
+ /** Subcommands that take a profile-name argument (position 2). */
624
+ const PROFILE_NAME_SUBS: ReadonlySet<string> = new Set([
625
+ "use",
626
+ "edit",
627
+ "show",
628
+ "delete",
629
+ "default",
630
+ "rename",
631
+ ]);
632
+
633
+ /** Aliases the /profile handler accepts, mapped to their canonical subcommand. */
634
+ const PROFILE_CMD_ALIASES: Readonly<Record<string, string>> = {
635
+ activate: "use",
636
+ config: "edit",
637
+ remove: "delete",
638
+ rm: "delete",
639
+ mv: "rename",
640
+ new: "create",
641
+ add: "create",
642
+ };
643
+
644
+ /** Write-scope descriptions for `/profile default <name> <scope>` completions. */
645
+ const SCOPE_DESCRIPTIONS: Readonly<Record<string, string>> = {
646
+ global: "write to ~/.pi/agent/settings.json",
647
+ project: "write to <cwd>/.pi/settings.json",
648
+ };
649
+
650
+ interface ProfileSubMeta {
651
+ name: string;
652
+ /** Takes an argument → completion `value` carries a trailing space. */
653
+ takesArg: boolean;
654
+ label: string;
655
+ description: string;
656
+ }
657
+
658
+ const PROFILE_SUBS: readonly ProfileSubMeta[] = [
659
+ {
660
+ name: "list",
661
+ takesArg: false,
662
+ label: "list",
663
+ description: "show all profiles",
664
+ },
665
+ {
666
+ name: "use",
667
+ takesArg: true,
668
+ label: "use <name|none>",
669
+ description: "activate/deactivate for this session",
670
+ },
671
+ {
672
+ name: "create",
673
+ takesArg: true,
674
+ label: "create <name>",
675
+ description: "create a profile (seeded from current models)",
676
+ },
677
+ {
678
+ name: "edit",
679
+ takesArg: true,
680
+ label: "edit <name>",
681
+ description: "open the tabbed model editor",
682
+ },
683
+ {
684
+ name: "rename",
685
+ takesArg: true,
686
+ label: "rename <old> <new>",
687
+ description: "rename a profile",
688
+ },
689
+ {
690
+ name: "delete",
691
+ takesArg: true,
692
+ label: "delete <name>",
693
+ description: "delete a profile",
694
+ },
695
+ {
696
+ name: "default",
697
+ takesArg: true,
698
+ label: "default [name|none] [global|project]",
699
+ description: "default profile for new sessions",
700
+ },
701
+ {
702
+ name: "show",
703
+ takesArg: true,
704
+ label: "show <name>",
705
+ description: "show one profile's models",
706
+ },
707
+ ];
708
+
709
+ /** Profile names matching a lowercased partial token, case-insensitively. */
710
+ function profileNameMatches(
711
+ profiles: readonly Profile[],
712
+ partial: string,
713
+ ): Profile[] {
714
+ if (!partial) return [...profiles];
715
+ return profiles.filter((p) =>
716
+ p.name.toLowerCase().startsWith(partial.toLowerCase()),
717
+ );
718
+ }
719
+
720
+ function profileCompletions(
721
+ profiles: readonly Profile[],
722
+ join: (token: string) => string,
723
+ ): ArgumentCompletion[] {
724
+ return profiles.map((p) => ({
725
+ value: join(p.name),
726
+ label: p.name,
727
+ ...(p.description ? { description: p.description } : {}),
728
+ }));
729
+ }
730
+
731
+ /**
732
+ * Argument completions for `/profile` (pure; unit-tested in profiles.test.ts).
733
+ *
734
+ * `argumentPrefix` is the ENTIRE typed argument text after "/profile " (what
735
+ * pi's CombinedAutocompleteProvider passes). Per the pi-tui contract, every
736
+ * returned item's `value` is the complete replacement for that argument text
737
+ * (`joinArgumentValue`), never a bare token — accepting `"use Syn-De" →
738
+ * "Syn-…"` would otherwise drop the `use` subcommand (see the /mcp
739
+ * extension for the reference implementation).
740
+ *
741
+ * Returns null when there is nothing useful to offer (unknown subcommand,
742
+ * `rename <old> <new>` second arg, `create`, …) or when the typed token is
743
+ * already an exact, unique match (exact-match suppression → the dropdown
744
+ * closes and the first Enter submits the command).
745
+ */
746
+ export function profileArgumentCompletions(
747
+ argumentPrefix: string,
748
+ profiles: readonly Profile[],
749
+ ): ArgumentCompletion[] | null {
750
+ const raw = String(argumentPrefix ?? "");
751
+ const join = (token: string) => joinArgumentValue(raw, token);
752
+
753
+ // ── Subcommand position: no whitespace typed yet ────────────────────
754
+ if (!raw.trim() || !/\s/.test(raw)) {
755
+ const lower = raw.trim().toLowerCase();
756
+ const matching = PROFILE_SUBS.filter((s) =>
757
+ s.name.startsWith(lower),
758
+ );
759
+ if (matching.length > 0) {
760
+ return matching.map((s) => ({
761
+ value: s.takesArg ? `${s.name} ` : s.name,
762
+ label: s.label,
763
+ description: s.description,
764
+ }));
765
+ }
766
+ // Bare profile name → `/profile <name>` shows detail (mirrors /role).
767
+ const names = profileNameMatches(profiles, lower);
768
+ if (names.length === 0) return null;
769
+ return profileCompletions(names, join);
770
+ }
771
+
772
+ // ── Argument position ────────────────────────────────────────────────
773
+ const tokens = raw.trim().split(/\s+/).filter(Boolean);
774
+ const head = tokens[0] ?? "";
775
+ const canonical = PROFILE_CMD_ALIASES[head.toLowerCase()] ?? head.toLowerCase();
776
+ const endsWithWs = /\s$/.test(raw);
777
+ const committedCount = endsWithWs ? tokens.length : tokens.length - 1;
778
+ const partial = endsWithWs ? "" : (tokens[tokens.length - 1] ?? "");
779
+
780
+ let items: ArgumentCompletion[] | null = null;
781
+
782
+ if (canonical === "default") {
783
+ if (committedCount <= 1) {
784
+ // Second token: profile name | none.
785
+ const names = profileNameMatches(profiles, partial);
786
+ const none: ArgumentCompletion[] =
787
+ "none".startsWith(partial.toLowerCase())
788
+ ? [{ value: join("none"), label: "none", description: "no profile" }]
789
+ : [];
790
+ items = [...profileCompletions(names, join), ...none];
791
+ } else {
792
+ // Third token: scope.
793
+ items = ["global", "project"].flatMap((s) =>
794
+ s.startsWith(partial.toLowerCase())
795
+ ? [
796
+ {
797
+ value: join(s),
798
+ label: s,
799
+ description: SCOPE_DESCRIPTIONS[s],
800
+ },
801
+ ]
802
+ : [],
803
+ );
804
+ }
805
+ } else if (canonical === "rename") {
806
+ // First arg: existing name; second arg (new name) is free text.
807
+ if (committedCount <= 1) {
808
+ items = profileCompletions(profileNameMatches(profiles, partial), join);
809
+ }
810
+ } else if (PROFILE_NAME_SUBS.has(canonical)) {
811
+ const names = profileNameMatches(profiles, partial);
812
+ const none: ArgumentCompletion[] =
813
+ canonical === "use" && "none".startsWith(partial.toLowerCase())
814
+ ? [{ value: join("none"), label: "none", description: "no profile" }]
815
+ : [];
816
+ items = [...profileCompletions(names, join), ...none];
817
+ }
818
+ // create|new|add (new name is free text), list, unknown → items stays null.
819
+
820
+ if (!items || items.length === 0) return null;
821
+ const candidateTokens = items.map((i) => i.label);
822
+ if (suppressExactArgumentMatch(candidateTokens, partial)) return null;
823
+ return items;
824
+ }
package/roles.ts CHANGED
@@ -13,6 +13,11 @@
13
13
  import * as fs from "node:fs";
14
14
  import * as path from "node:path";
15
15
  import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
16
+ import type { ArgumentCompletion } from "./utils.ts";
17
+ import {
18
+ joinArgumentValue,
19
+ suppressExactArgumentMatch,
20
+ } from "./utils.ts";
16
21
 
17
22
  // ── Types ────────────────────────────────────────────────────────────
18
23
 
@@ -430,3 +435,140 @@ export function formatRolesForDisplay(config: RoleConfig): string {
430
435
  );
431
436
  return lines.join("\n");
432
437
  }
438
+
439
+ // ── Argument completions (/role) ─────────────────────────────────────
440
+
441
+ interface RoleSubMeta {
442
+ name: string;
443
+ /** Takes an argument → completion `value` carries a trailing space. */
444
+ takesArg: boolean;
445
+ label: string;
446
+ description: string;
447
+ }
448
+
449
+ const ROLE_SUBS: readonly RoleSubMeta[] = [
450
+ { name: "list", takesArg: false, label: "list", description: "show all roles" },
451
+ {
452
+ name: "show",
453
+ takesArg: false,
454
+ label: "show",
455
+ description: "show all roles (alias of list)",
456
+ },
457
+ {
458
+ name: "config",
459
+ takesArg: true,
460
+ label: "config",
461
+ description: "open the role config screen (picker)",
462
+ },
463
+ {
464
+ name: "set",
465
+ takesArg: true,
466
+ label: "set <role> <provider/model> [thinking]",
467
+ description: "configure one role's model",
468
+ },
469
+ {
470
+ name: "add",
471
+ takesArg: true,
472
+ label: "add <name> [provider/model] [thinking]",
473
+ description: "add a custom role",
474
+ },
475
+ {
476
+ name: "remove",
477
+ takesArg: true,
478
+ label: "remove <name>",
479
+ description: "remove a custom role",
480
+ },
481
+ {
482
+ name: "reset",
483
+ takesArg: false,
484
+ label: "reset",
485
+ description: "reset roles to defaults",
486
+ },
487
+ {
488
+ name: "use",
489
+ takesArg: true,
490
+ label: "use <role|auto>",
491
+ description: "activate one role for the whole workflow",
492
+ },
493
+ ];
494
+
495
+ /**
496
+ * Argument completions for `/role` (pure; unit-tested in roles.test.ts).
497
+ *
498
+ * Follows the pi-tui full-argument contract (see `joinArgumentValue` and the
499
+ * /mcp extension): every returned `value` is the complete replacement for the
500
+ * typed argument text — `set planner opencode-go/muse-…`, never a bare token.
501
+ * Positional rules mirror the /role handler:
502
+ * - `set` arg1 = existing role names, arg2 = model refs (`provider/id`,
503
+ * null when `opts.models` is empty), arg3 = thinking levels;
504
+ * - `use` = role names + `auto`;
505
+ * - `remove` = role names;
506
+ * - `add` arg1 is a NEW name → no completions.
507
+ * Returns null when nothing useful can be offered, including when the typed
508
+ * token is already an exact unique match (exact-match suppression — the
509
+ * dropdown closes so the first Enter submits).
510
+ */
511
+ export function roleArgumentCompletions(
512
+ argumentPrefix: string,
513
+ opts: { roleNames: readonly string[]; models?: readonly string[] },
514
+ ): ArgumentCompletion[] | null {
515
+ const raw = String(argumentPrefix ?? "");
516
+ const join = (token: string) => joinArgumentValue(raw, token);
517
+
518
+ // ── Subcommand position: no whitespace typed yet ────────────────────
519
+ if (!raw.trim() || !/\s/.test(raw)) {
520
+ const lower = raw.trim().toLowerCase();
521
+ const matching = ROLE_SUBS.filter((s) => s.name.startsWith(lower));
522
+ if (matching.length === 0) return null;
523
+ return matching.map((s) => ({
524
+ value: s.takesArg ? `${s.name} ` : s.name,
525
+ label: s.label,
526
+ description: s.description,
527
+ }));
528
+ }
529
+
530
+ // ── Argument position ────────────────────────────────────────────────
531
+ const tokens = raw.trim().split(/\s+/).filter(Boolean);
532
+ const cmd = (tokens[0] ?? "").toLowerCase();
533
+ const endsWithWs = /\s$/.test(raw);
534
+ const committedCount = endsWithWs ? tokens.length : tokens.length - 1;
535
+ const partial = endsWithWs ? "" : (tokens[tokens.length - 1] ?? "");
536
+ const matchToken = (t: string) => t.toLowerCase().startsWith(partial.toLowerCase());
537
+
538
+ const roleNameItems = (): ArgumentCompletion[] =>
539
+ opts.roleNames.flatMap((n) =>
540
+ matchToken(n) ? [{ value: join(n), label: n }] : [],
541
+ );
542
+
543
+ let items: ArgumentCompletion[] | null = null;
544
+
545
+ if (cmd === "set") {
546
+ if (committedCount <= 1) {
547
+ items = roleNameItems();
548
+ } else if (committedCount === 2) {
549
+ items = (opts.models ?? []).flatMap((m) =>
550
+ matchToken(m) ? [{ value: join(m), label: m }] : [],
551
+ );
552
+ } else {
553
+ items = VALID_THINKING_LEVELS.flatMap((t) =>
554
+ matchToken(t) ? [{ value: join(t), label: t }] : [],
555
+ );
556
+ }
557
+ } else if (cmd === "use") {
558
+ items = [
559
+ ...roleNameItems(),
560
+ ...("auto".startsWith(partial.toLowerCase())
561
+ ? [{ value: join("auto"), label: "auto" }]
562
+ : []),
563
+ ];
564
+ } else if (cmd === "remove") {
565
+ items = roleNameItems();
566
+ }
567
+ // add (new name is free text), config, list/show, reset, unknown → null.
568
+
569
+ if (!items || items.length === 0) return null;
570
+ if (suppressExactArgumentMatch(items.map((i) => i.label), partial)) {
571
+ return null;
572
+ }
573
+ return items;
574
+ }
package/utils.ts CHANGED
@@ -1583,3 +1583,63 @@ export function renderReviewContextBlock(
1583
1583
  }
1584
1584
  return out;
1585
1585
  }
1586
+
1587
+ // ── Slash-command argument completions (pi-tui contract) ─────────────
1588
+ //
1589
+ // `CombinedAutocompleteProvider.getSuggestions` (pi-tui dist/autocomplete.js)
1590
+ // passes `getArgumentCompletions(argumentText)` the ENTIRE typed argument
1591
+ // text after the command name and, on accept, `applyCompletion` replaces
1592
+ // that whole argument text with `item.value` (the slash-argument branch:
1593
+ // `newLine = beforePrefix + item.value`). So an argument-position item's
1594
+ // `value` must be the complete replacement — e.g. `"use <name>"`, never the
1595
+ // bare token. Pi's own /mcp extension follows this contract
1596
+ // (`value: `${action} ${name}``). `joinArgumentValue` is the single helper
1597
+ // that enforces it: it keeps every committed token and appends (or replaces
1598
+ // the last partial token with) `token`.
1599
+
1600
+ /** One slash-command argument completion item (pi-tui `AutocompleteItem` shape). */
1601
+ export interface ArgumentCompletion {
1602
+ value: string;
1603
+ label: string;
1604
+ description?: string;
1605
+ }
1606
+
1607
+ /**
1608
+ * Build the full-argument replacement `value` for a completion token.
1609
+ *
1610
+ * `prefix` is the typed argument text so far. Tokens separated by whitespace
1611
+ * are "committed"; if `prefix` ends with whitespace the user is starting a
1612
+ * fresh token, so `token` is appended — otherwise it replaces the trailing
1613
+ * partial token. Pure; never throws.
1614
+ *
1615
+ * joinArgumentValue("use Syn-De", "Syn-Deepseek-v4.1-Flash-Planner")
1616
+ * → "use Syn-Deepseek-v4.1-Flash-Planner"
1617
+ * joinArgumentValue("use ", "none") → "use none"
1618
+ * joinArgumentValue("", "list") → "list"
1619
+ */
1620
+ export function joinArgumentValue(prefix: string, token: string): string {
1621
+ const raw = String(prefix ?? "");
1622
+ const endsWithWs = /\s$/.test(raw);
1623
+ const parts = raw.trim().split(/\s+/).filter(Boolean);
1624
+ const committed = endsWithWs ? parts : parts.slice(0, -1);
1625
+ return [...committed, token].join(" ");
1626
+ }
1627
+
1628
+ /**
1629
+ * Exact-match suppression for slash-command argument completions: when the
1630
+ * token being typed (`partial`, "" while the prefix ends with whitespace)
1631
+ * already equals the ONLY remaining candidate, return true so the completer
1632
+ * yields null — pi's editor then closes the dropdown and the FIRST Enter
1633
+ * submits instead of being consumed by the completion-accept handler
1634
+ * (editor.js `tui.select.confirm`). Pure.
1635
+ */
1636
+ export function suppressExactArgumentMatch(
1637
+ candidates: readonly string[],
1638
+ partial: string,
1639
+ ): boolean {
1640
+ if (!partial) return false;
1641
+ return (
1642
+ candidates.length === 1 &&
1643
+ candidates[0].toLowerCase() === partial.toLowerCase()
1644
+ );
1645
+ }