@trim21/personal-pi-extensions 0.0.208 → 0.0.210
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/package.json +1 -1
- package/src/bwrap/index.ts +87 -25
- package/src/lib/cli-args.ts +116 -0
- package/src/lib/cli.ts +340 -0
- package/src/talk/core.ts +72 -70
- package/src/talk/format.ts +14 -15
- package/src/talk/group.ts +11 -14
- package/src/talk/index.ts +156 -84
- package/src/talk/mailbox.ts +27 -6
- package/src/talk/policy.ts +1 -1
- package/src/talk/registry.ts +50 -27
- package/src/talk/skills/multi-agent-dev/SKILL.md +27 -26
- package/src/talk/storage.ts +1 -1
package/package.json
CHANGED
package/src/bwrap/index.ts
CHANGED
|
@@ -62,11 +62,12 @@ import { access as fsAccess } from "node:fs/promises";
|
|
|
62
62
|
import { delimiter, join } from "node:path";
|
|
63
63
|
import { fileURLToPath } from "node:url";
|
|
64
64
|
|
|
65
|
-
import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
|
|
65
|
+
import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
66
66
|
import { type BashOperations, createBashTool, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
67
|
-
import { Type } from "typebox";
|
|
67
|
+
import { type TObject, Type } from "typebox";
|
|
68
68
|
import { Value } from "typebox/value";
|
|
69
69
|
|
|
70
|
+
import { type CommandSpec, parseCommand } from "../lib/cli.js";
|
|
70
71
|
import { expandHome } from "../lib/path.js";
|
|
71
72
|
|
|
72
73
|
const SANDBOX_PROMPT = `
|
|
@@ -711,25 +712,80 @@ export default function bwrapExtension(pi: ExtensionAPI) {
|
|
|
711
712
|
};
|
|
712
713
|
});
|
|
713
714
|
|
|
714
|
-
|
|
715
|
+
const BWRAP_SPEC = {
|
|
716
|
+
name: "bwrap",
|
|
717
|
+
usage: "",
|
|
715
718
|
description: "Show bwrap sandbox configuration",
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
719
|
+
flags: Type.Object({}),
|
|
720
|
+
arity: { max: 0 },
|
|
721
|
+
};
|
|
722
|
+
|
|
723
|
+
const BWRAP_ALLOW_ALL_SPEC = {
|
|
724
|
+
name: "bwrap-allow-all",
|
|
725
|
+
usage: "",
|
|
726
|
+
description: "Disable bwrap sandbox, full access",
|
|
727
|
+
flags: Type.Object({}),
|
|
728
|
+
arity: { max: 0 },
|
|
729
|
+
};
|
|
722
730
|
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
731
|
+
const BWRAP_WORKSPACE_WRITE_SPEC = {
|
|
732
|
+
name: "bwrap-workspace-write",
|
|
733
|
+
usage: "",
|
|
734
|
+
description: "Sandbox on, network off, workspace writable",
|
|
735
|
+
flags: Type.Object({}),
|
|
736
|
+
arity: { max: 0 },
|
|
737
|
+
};
|
|
726
738
|
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
739
|
+
const BWRAP_ALLOW_NET_SPEC = {
|
|
740
|
+
name: "bwrap-allow-net",
|
|
741
|
+
usage: "",
|
|
742
|
+
description: "Sandbox on, network on, workspace writable",
|
|
743
|
+
flags: Type.Object({}),
|
|
744
|
+
arity: { max: 0 },
|
|
745
|
+
};
|
|
746
|
+
|
|
747
|
+
const BWRAP_READONLY_SPEC = {
|
|
748
|
+
name: "bwrap-readonly",
|
|
749
|
+
usage: "",
|
|
750
|
+
description: "Sandbox on, network off, no writes",
|
|
751
|
+
flags: Type.Object({}),
|
|
752
|
+
arity: { max: 0 },
|
|
753
|
+
};
|
|
754
|
+
|
|
755
|
+
/** Parse a /bwrap command; on help/error the text is sent to the session, otherwise run() executes. */
|
|
756
|
+
function runBwrapCommand(
|
|
757
|
+
spec: CommandSpec<TObject>,
|
|
758
|
+
args: string,
|
|
759
|
+
ctx: ExtensionCommandContext,
|
|
760
|
+
run: (ctx: ExtensionCommandContext) => void | Promise<void>,
|
|
761
|
+
): Promise<void> {
|
|
762
|
+
const parsed = parseCommand(spec, args);
|
|
763
|
+
if (parsed.kind !== "ok") {
|
|
764
|
+
pi.sendMessage({ customType: "info", content: parsed.text, display: true });
|
|
731
765
|
return Promise.resolve();
|
|
732
|
-
}
|
|
766
|
+
}
|
|
767
|
+
return Promise.resolve(run(ctx));
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
pi.registerCommand("bwrap", {
|
|
771
|
+
description: BWRAP_SPEC.description,
|
|
772
|
+
handler: (args, ctx) =>
|
|
773
|
+
runBwrapCommand(BWRAP_SPEC, args, ctx, (c) => {
|
|
774
|
+
const r = getResolved(c.hasUI);
|
|
775
|
+
if (!r.bwrapEnabled) {
|
|
776
|
+
c.ui.notify(`bwrap disabled (mode: ${r.mode})`, "info");
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const net = r.network ? "net" : "no-net";
|
|
781
|
+
const w = r.writablePaths.map((p) => resolvePath(p, localCwd));
|
|
782
|
+
const t = r.tmpfsPaths.map((p) => resolvePath(p, localCwd));
|
|
783
|
+
|
|
784
|
+
c.ui.notify(
|
|
785
|
+
`bwrap ${r.mode} ${net} write:[${w.join(", ")}] tmpfs:[${t.join(", ") || "-"}]`,
|
|
786
|
+
"info",
|
|
787
|
+
);
|
|
788
|
+
}),
|
|
733
789
|
});
|
|
734
790
|
|
|
735
791
|
function switchMode(
|
|
@@ -760,22 +816,28 @@ export default function bwrapExtension(pi: ExtensionAPI) {
|
|
|
760
816
|
}
|
|
761
817
|
|
|
762
818
|
pi.registerCommand("bwrap-allow-all", {
|
|
763
|
-
description:
|
|
764
|
-
handler: (
|
|
819
|
+
description: BWRAP_ALLOW_ALL_SPEC.description,
|
|
820
|
+
handler: (args, ctx) =>
|
|
821
|
+
runBwrapCommand(BWRAP_ALLOW_ALL_SPEC, args, ctx, (c) => switchMode("allow-all", c)),
|
|
765
822
|
});
|
|
766
823
|
|
|
767
824
|
pi.registerCommand("bwrap-workspace-write", {
|
|
768
|
-
description:
|
|
769
|
-
handler: (
|
|
825
|
+
description: BWRAP_WORKSPACE_WRITE_SPEC.description,
|
|
826
|
+
handler: (args, ctx) =>
|
|
827
|
+
runBwrapCommand(BWRAP_WORKSPACE_WRITE_SPEC, args, ctx, (c) =>
|
|
828
|
+
switchMode("workspace-write", c),
|
|
829
|
+
),
|
|
770
830
|
});
|
|
771
831
|
|
|
772
832
|
pi.registerCommand("bwrap-allow-net", {
|
|
773
|
-
description:
|
|
774
|
-
handler: (
|
|
833
|
+
description: BWRAP_ALLOW_NET_SPEC.description,
|
|
834
|
+
handler: (args, ctx) =>
|
|
835
|
+
runBwrapCommand(BWRAP_ALLOW_NET_SPEC, args, ctx, (c) => switchMode("allow-net", c)),
|
|
775
836
|
});
|
|
776
837
|
|
|
777
838
|
pi.registerCommand("bwrap-readonly", {
|
|
778
|
-
description:
|
|
779
|
-
handler: (
|
|
839
|
+
description: BWRAP_READONLY_SPEC.description,
|
|
840
|
+
handler: (args, ctx) =>
|
|
841
|
+
runBwrapCommand(BWRAP_READONLY_SPEC, args, ctx, (c) => switchMode("readonly", c)),
|
|
780
842
|
});
|
|
781
843
|
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal command-line argument parsing for `/command` handlers.
|
|
3
|
+
*
|
|
4
|
+
* The pi command API passes handlers a raw string (current versions) or a
|
|
5
|
+
* token array (newer ones); this helper accepts either and yields
|
|
6
|
+
* positionals plus flags, so handlers never care about the input shape.
|
|
7
|
+
*
|
|
8
|
+
* A raw string is split with shell-like rules first: whitespace separates
|
|
9
|
+
* tokens, single quotes preserve everything literally, double quotes allow
|
|
10
|
+
* `\"` / `\\` escapes, and a backslash outside quotes escapes the next
|
|
11
|
+
* character. Unlike bash, empty tokens are dropped and an unterminated
|
|
12
|
+
* quote raises a SyntaxError.
|
|
13
|
+
*
|
|
14
|
+
* Flags (only long form, `--name`):
|
|
15
|
+
* --name value flag "name" = "value" (value may not start with `--`)
|
|
16
|
+
* --name=value same
|
|
17
|
+
* --flag boolean flag = true
|
|
18
|
+
* -- everything after is a positional
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export interface ParsedArgs {
|
|
22
|
+
/** Non-flag arguments, in order. */
|
|
23
|
+
positionals: string[];
|
|
24
|
+
/** `--name value` / `--name=value` → string; `--flag` → true. */
|
|
25
|
+
flags: Record<string, string | boolean>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Does this token look like a long flag (`--x`, but not the bare `--`)? */
|
|
29
|
+
function isFlagToken(token: string): boolean {
|
|
30
|
+
return token.startsWith("--") && token !== "--";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Shell-like tokenizer for a raw command line. Empty tokens are dropped. */
|
|
34
|
+
export function shlexSplit(raw: string): string[] {
|
|
35
|
+
const tokens: string[] = [];
|
|
36
|
+
let cur = "";
|
|
37
|
+
let quote: "'" | '"' | undefined;
|
|
38
|
+
let i = 0;
|
|
39
|
+
while (i < raw.length) {
|
|
40
|
+
const c = raw[i];
|
|
41
|
+
if (quote) {
|
|
42
|
+
if (c === quote) {
|
|
43
|
+
quote = undefined;
|
|
44
|
+
} else if (c === "\\" && quote === '"' && (raw[i + 1] === '"' || raw[i + 1] === "\\")) {
|
|
45
|
+
cur += raw[i + 1];
|
|
46
|
+
i++;
|
|
47
|
+
} else {
|
|
48
|
+
cur += c;
|
|
49
|
+
}
|
|
50
|
+
i++;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (c === "'" || c === '"') {
|
|
54
|
+
quote = c;
|
|
55
|
+
i++;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (c === "\\") {
|
|
59
|
+
if (i + 1 < raw.length) cur += raw[i + 1];
|
|
60
|
+
i += 2;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (/\s/.test(c)) {
|
|
64
|
+
if (cur) {
|
|
65
|
+
tokens.push(cur);
|
|
66
|
+
cur = "";
|
|
67
|
+
}
|
|
68
|
+
i++;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
cur += c;
|
|
72
|
+
i++;
|
|
73
|
+
}
|
|
74
|
+
if (quote !== undefined) {
|
|
75
|
+
throw new SyntaxError(`unterminated quote in command arguments: ${raw}`);
|
|
76
|
+
}
|
|
77
|
+
if (cur) tokens.push(cur);
|
|
78
|
+
return tokens;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function parseArgs(input: string | string[]): ParsedArgs {
|
|
82
|
+
const tokens = Array.isArray(input) ? input : shlexSplit(input);
|
|
83
|
+
const positionals: string[] = [];
|
|
84
|
+
const flags: Record<string, string | boolean> = {};
|
|
85
|
+
let positionalOnly = false;
|
|
86
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
87
|
+
const token = tokens[i];
|
|
88
|
+
if (positionalOnly) {
|
|
89
|
+
positionals.push(token);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (token === "--") {
|
|
93
|
+
positionalOnly = true;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const m = /^--([a-zA-Z0-9][a-zA-Z0-9-]*)(?:=(.*))?$/.exec(token);
|
|
97
|
+
if (!m) {
|
|
98
|
+
positionals.push(token);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const name = m[1];
|
|
102
|
+
if (m[2] !== undefined) {
|
|
103
|
+
flags[name] = m[2];
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
// `--name value` form: consume the next token unless it is itself a flag.
|
|
107
|
+
const next = tokens[i + 1];
|
|
108
|
+
if (next !== undefined && !isFlagToken(next)) {
|
|
109
|
+
flags[name] = next;
|
|
110
|
+
i++;
|
|
111
|
+
} else {
|
|
112
|
+
flags[name] = true;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return { positionals, flags };
|
|
116
|
+
}
|
package/src/lib/cli.ts
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declarative command-line parsing for `/command` handlers.
|
|
3
|
+
*
|
|
4
|
+
* The shell-like tokenizer from `cli-args.ts` splits the raw argument string;
|
|
5
|
+
* this module parses the tokens into positional args plus typed flags. Flags
|
|
6
|
+
* are declared as a typebox object schema: each property is a flag and the
|
|
7
|
+
* property key is the long name (`--key`). The property schema decides the
|
|
8
|
+
* flag kind:
|
|
9
|
+
*
|
|
10
|
+
* Type.Boolean() boolean flag (`--flag`)
|
|
11
|
+
* Type.String() string value flag (`--flag value`)
|
|
12
|
+
* Type.Number() / Type.Integer() number value flag (value is coerced)
|
|
13
|
+
* Type.Union([Type.Literal(...)]) string enum value flag (validated)
|
|
14
|
+
*
|
|
15
|
+
* Supported syntax (common unix CLI conventions):
|
|
16
|
+
*
|
|
17
|
+
* -s short boolean flag
|
|
18
|
+
* -s value short flag with value (also `-s=value` and `-svalue`)
|
|
19
|
+
* -abc combined short boolean flags
|
|
20
|
+
* --long long boolean flag
|
|
21
|
+
* --long=value long flag with inline value
|
|
22
|
+
* --long value long flag consuming the next token
|
|
23
|
+
* -- everything after is a positional
|
|
24
|
+
*
|
|
25
|
+
* `Type.Optional(...)` marks a flag optional (no error when absent) and a
|
|
26
|
+
* `default` fills in a missing value; both flow through typebox's value
|
|
27
|
+
* pipeline (default → convert → check) so the `ok` result carries
|
|
28
|
+
* `Static<TFlags>` flags plus `string[]` args.
|
|
29
|
+
*
|
|
30
|
+
* `-h`/`--help` is registered automatically unless the schema declares a
|
|
31
|
+
* `help` property or a short `h` alias; it renders a usage/options text as
|
|
32
|
+
* the result so handlers can display it in chat instead of writing stdout.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import type { Static, TObject } from "typebox";
|
|
36
|
+
import { Value } from "typebox/value";
|
|
37
|
+
|
|
38
|
+
import { shlexSplit } from "./cli-args.js";
|
|
39
|
+
|
|
40
|
+
/** Per-flag CLI metadata on top of the typebox schema. */
|
|
41
|
+
export interface FlagMeta {
|
|
42
|
+
/** Optional short alias (single character), e.g. "n" for `-n`. */
|
|
43
|
+
short?: string;
|
|
44
|
+
/** Help line for this flag; defaults to the schema `description`. */
|
|
45
|
+
description?: string;
|
|
46
|
+
/** Placeholder shown for value flags in help, e.g. "<alias>"; defaults to `<key>`. */
|
|
47
|
+
valuePlaceholder?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface CommandSpec<TFlags extends TObject> {
|
|
51
|
+
/** Command name used in usage/help, e.g. "talk-group-join". */
|
|
52
|
+
name: string;
|
|
53
|
+
/** Usage text after the command name, e.g. "[group name] [options]". */
|
|
54
|
+
usage: string;
|
|
55
|
+
/** One-line description shown under the usage line. */
|
|
56
|
+
description?: string;
|
|
57
|
+
/** Typebox object schema describing the flags (key = long flag name). */
|
|
58
|
+
flags: TFlags;
|
|
59
|
+
/** Per-flag CLI metadata (short alias, help text). */
|
|
60
|
+
flagMeta?: { [K in keyof Static<TFlags>]?: FlagMeta };
|
|
61
|
+
/** Positional count constraints. */
|
|
62
|
+
arity?: { min?: number; max?: number };
|
|
63
|
+
/** Example lines rendered under the help text. */
|
|
64
|
+
examples?: string[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type CommandResult<TFlags extends TObject> =
|
|
68
|
+
| { kind: "ok"; flags: Static<TFlags>; args: string[] }
|
|
69
|
+
| { kind: "help"; text: string }
|
|
70
|
+
| { kind: "error"; text: string };
|
|
71
|
+
|
|
72
|
+
/** Runtime view of a flag schema (typebox's `TSchema` is empty at the type level). */
|
|
73
|
+
interface FlagSchema {
|
|
74
|
+
"~kind"?: string;
|
|
75
|
+
"~optional"?: boolean;
|
|
76
|
+
type?: string;
|
|
77
|
+
default?: unknown;
|
|
78
|
+
description?: string;
|
|
79
|
+
anyOf?: { type?: string; const?: unknown }[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
type FlagKind = "boolean" | "string" | "number" | "enum";
|
|
83
|
+
|
|
84
|
+
interface FlagInfo {
|
|
85
|
+
key: string;
|
|
86
|
+
kind: FlagKind;
|
|
87
|
+
short?: string;
|
|
88
|
+
required: boolean;
|
|
89
|
+
placeholder: string;
|
|
90
|
+
description: string;
|
|
91
|
+
schema: FlagSchema;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function kindOf(key: string, schema: FlagSchema): FlagKind {
|
|
95
|
+
switch (schema["~kind"]) {
|
|
96
|
+
case "Boolean": {
|
|
97
|
+
return "boolean";
|
|
98
|
+
}
|
|
99
|
+
case "String": {
|
|
100
|
+
return "string";
|
|
101
|
+
}
|
|
102
|
+
case "Number":
|
|
103
|
+
case "Integer": {
|
|
104
|
+
return "number";
|
|
105
|
+
}
|
|
106
|
+
case "Union": {
|
|
107
|
+
return "enum";
|
|
108
|
+
}
|
|
109
|
+
default: {
|
|
110
|
+
throw new TypeError(
|
|
111
|
+
`Unsupported flag type for '${key}': ${schema["~kind"] ?? schema.type ?? "unknown"} ` +
|
|
112
|
+
"(use Type.Boolean/String/Number/Integer or a string literal union)",
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Allowed values for a string-literal union flag, or undefined for mixed unions. */
|
|
119
|
+
function enumValues(schema: FlagSchema): string[] | undefined {
|
|
120
|
+
const anyOf = schema.anyOf;
|
|
121
|
+
if (!anyOf) return undefined;
|
|
122
|
+
const values = anyOf.map((s) => s.const).filter((c) => typeof c === "string");
|
|
123
|
+
return values.length === anyOf.length ? values : undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function buildFlagInfos<TFlags extends TObject>(spec: CommandSpec<TFlags>): FlagInfo[] {
|
|
127
|
+
const meta = spec.flagMeta as Record<string, FlagMeta> | undefined;
|
|
128
|
+
const infos: FlagInfo[] = [];
|
|
129
|
+
for (const [key, rawSchema] of Object.entries(spec.flags.properties)) {
|
|
130
|
+
const schema = rawSchema as unknown as FlagSchema;
|
|
131
|
+
const m = meta?.[key];
|
|
132
|
+
infos.push({
|
|
133
|
+
key,
|
|
134
|
+
kind: kindOf(key, schema),
|
|
135
|
+
short: m?.short,
|
|
136
|
+
required: schema["~optional"] !== true && schema.default === undefined,
|
|
137
|
+
placeholder: m?.valuePlaceholder ?? `<${key}>`,
|
|
138
|
+
description: m?.description ?? schema.description ?? "",
|
|
139
|
+
schema,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return infos;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Is this token a flag-like argument (a negative number is a value)? */
|
|
146
|
+
function looksLikeFlag(token: string): boolean {
|
|
147
|
+
return token.startsWith("-") && !/^-\d/.test(token);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function errorResult<TFlags extends TObject>(
|
|
151
|
+
spec: CommandSpec<TFlags>,
|
|
152
|
+
message: string,
|
|
153
|
+
): CommandResult<TFlags> {
|
|
154
|
+
return { kind: "error", text: `${message}\nTry '/${spec.name} --help' for usage.` };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function helpResult<TFlags extends TObject>(
|
|
158
|
+
spec: CommandSpec<TFlags>,
|
|
159
|
+
flags: FlagInfo[],
|
|
160
|
+
autoHelp: boolean,
|
|
161
|
+
): CommandResult<TFlags> {
|
|
162
|
+
const lines = [`Usage: /${spec.name} ${spec.usage}`];
|
|
163
|
+
if (spec.description) lines.push("", spec.description);
|
|
164
|
+
const rows = flags.map((f) => ({
|
|
165
|
+
rawName:
|
|
166
|
+
(f.short ? `-${f.short}, ` : "") +
|
|
167
|
+
`--${f.key}` +
|
|
168
|
+
(f.kind === "boolean" ? "" : ` ${f.placeholder}`),
|
|
169
|
+
description: f.description,
|
|
170
|
+
}));
|
|
171
|
+
if (autoHelp) rows.push({ rawName: "-h, --help", description: "Display this message" });
|
|
172
|
+
if (rows.length > 0) {
|
|
173
|
+
lines.push("", "Options:");
|
|
174
|
+
const width = Math.max(...rows.map((r) => r.rawName.length));
|
|
175
|
+
for (const r of rows) lines.push(` ${r.rawName.padEnd(width)} ${r.description}`);
|
|
176
|
+
}
|
|
177
|
+
if (spec.examples?.length) {
|
|
178
|
+
lines.push("", "Examples:");
|
|
179
|
+
for (const e of spec.examples) lines.push(` ${e}`);
|
|
180
|
+
}
|
|
181
|
+
return { kind: "help", text: lines.join("\n") };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function parseCommand<TFlags extends TObject>(
|
|
185
|
+
spec: CommandSpec<TFlags>,
|
|
186
|
+
raw: string,
|
|
187
|
+
): CommandResult<TFlags> {
|
|
188
|
+
const flags = buildFlagInfos(spec);
|
|
189
|
+
const byLong = new Map(flags.map((f) => [f.key, f]));
|
|
190
|
+
const byShort = new Map<string, FlagInfo>();
|
|
191
|
+
for (const f of flags) {
|
|
192
|
+
if (!f.short) {
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (byShort.has(f.short)) {
|
|
197
|
+
throw new TypeError(`Duplicate short option '-${f.short}' in /${spec.name}`);
|
|
198
|
+
}
|
|
199
|
+
byShort.set(f.short, f);
|
|
200
|
+
}
|
|
201
|
+
const autoHelp = !byLong.has("help") && !byShort.has("h");
|
|
202
|
+
|
|
203
|
+
let tokens: string[];
|
|
204
|
+
try {
|
|
205
|
+
tokens = shlexSplit(raw);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
return errorResult(spec, error instanceof Error ? error.message : String(error));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const rawFlags: Record<string, unknown> = {};
|
|
211
|
+
const args: string[] = [];
|
|
212
|
+
let positionalOnly = false;
|
|
213
|
+
|
|
214
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
215
|
+
const token = tokens[i];
|
|
216
|
+
if (positionalOnly) {
|
|
217
|
+
args.push(token);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (token === "--") {
|
|
221
|
+
positionalOnly = true;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (token === "-") {
|
|
225
|
+
args.push(token);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (token.startsWith("--")) {
|
|
229
|
+
const body = token.slice(2);
|
|
230
|
+
const eq = body.indexOf("=");
|
|
231
|
+
const name = eq === -1 ? body : body.slice(0, eq);
|
|
232
|
+
if (autoHelp && name === "help") return helpResult(spec, flags, autoHelp);
|
|
233
|
+
const info = byLong.get(name);
|
|
234
|
+
if (!info) return errorResult(spec, `Unknown option '--${name}'`);
|
|
235
|
+
const inline = eq === -1 ? undefined : body.slice(eq + 1);
|
|
236
|
+
if (inline !== undefined) {
|
|
237
|
+
rawFlags[info.key] = inline;
|
|
238
|
+
} else if (info.kind === "boolean") {
|
|
239
|
+
rawFlags[info.key] = true;
|
|
240
|
+
} else {
|
|
241
|
+
const next = tokens[i + 1];
|
|
242
|
+
if (next !== undefined && !looksLikeFlag(next)) {
|
|
243
|
+
rawFlags[info.key] = next;
|
|
244
|
+
i++;
|
|
245
|
+
} else if (autoHelp && next !== undefined && (next === "--help" || next === "-h")) {
|
|
246
|
+
return helpResult(spec, flags, autoHelp);
|
|
247
|
+
} else {
|
|
248
|
+
return errorResult(spec, `Option '--${name}' requires a value`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (token.startsWith("-")) {
|
|
254
|
+
const rest = token.slice(1);
|
|
255
|
+
for (let j = 0; j < rest.length; j++) {
|
|
256
|
+
const c = rest[j];
|
|
257
|
+
if (autoHelp && c === "h") return helpResult(spec, flags, autoHelp);
|
|
258
|
+
const info = byShort.get(c);
|
|
259
|
+
if (!info) return errorResult(spec, `Unknown option '-${c}'`);
|
|
260
|
+
if (info.kind === "boolean") {
|
|
261
|
+
rawFlags[info.key] = true;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (rest[j + 1] === "=") {
|
|
265
|
+
rawFlags[info.key] = rest.slice(j + 2);
|
|
266
|
+
} else if (j + 1 < rest.length) {
|
|
267
|
+
rawFlags[info.key] = rest.slice(j + 1);
|
|
268
|
+
} else {
|
|
269
|
+
const next = tokens[i + 1];
|
|
270
|
+
if (next !== undefined && !looksLikeFlag(next)) {
|
|
271
|
+
rawFlags[info.key] = next;
|
|
272
|
+
i++;
|
|
273
|
+
} else if (autoHelp && next !== undefined && (next === "--help" || next === "-h")) {
|
|
274
|
+
return helpResult(spec, flags, autoHelp);
|
|
275
|
+
} else {
|
|
276
|
+
return errorResult(spec, `Option '-${c}' requires a value`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
args.push(token);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
for (const f of flags) {
|
|
287
|
+
if (f.required && !(f.key in rawFlags)) {
|
|
288
|
+
return errorResult(spec, `Missing required option '--${f.key}'`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
for (const f of flags) {
|
|
293
|
+
if (f.kind === "enum" && typeof rawFlags[f.key] === "string") {
|
|
294
|
+
const values = enumValues(f.schema);
|
|
295
|
+
if (values && !values.includes(rawFlags[f.key] as string)) {
|
|
296
|
+
return errorResult(
|
|
297
|
+
spec,
|
|
298
|
+
`Invalid value for '--${f.key}': '${String(rawFlags[f.key])}' (expected one of: ${values.join(", ")})`,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (f.kind === "number" && typeof rawFlags[f.key] === "string") {
|
|
303
|
+
const n = Number(rawFlags[f.key]);
|
|
304
|
+
if (Number.isNaN(n)) {
|
|
305
|
+
return errorResult(spec, `Invalid value for '--${f.key}': '${String(rawFlags[f.key])}'`);
|
|
306
|
+
}
|
|
307
|
+
rawFlags[f.key] = n;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const { min, max } = spec.arity ?? {};
|
|
312
|
+
if (max !== undefined && args.length > max) {
|
|
313
|
+
const extra = args
|
|
314
|
+
.slice(max)
|
|
315
|
+
.map((a) => `'${a}'`)
|
|
316
|
+
.join(", ");
|
|
317
|
+
return errorResult(spec, `Too many arguments: ${extra} (expected at most ${max})`);
|
|
318
|
+
}
|
|
319
|
+
if (min !== undefined && args.length < min) {
|
|
320
|
+
return errorResult(spec, `Missing required argument: expected ${spec.usage}`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// typebox value pipeline: defaults → coercion → check.
|
|
324
|
+
let parsed: unknown;
|
|
325
|
+
try {
|
|
326
|
+
parsed = Value.Default(spec.flags, rawFlags);
|
|
327
|
+
parsed = Value.Convert(spec.flags, Value.Clone(parsed));
|
|
328
|
+
if (!Value.Check(spec.flags, parsed)) {
|
|
329
|
+
const [first] = [...Value.Errors(spec.flags, parsed)];
|
|
330
|
+
return errorResult(
|
|
331
|
+
spec,
|
|
332
|
+
`Invalid arguments: ${first?.message ?? "value does not match flags"}`,
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
} catch {
|
|
336
|
+
return errorResult(spec, "Invalid arguments");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return { kind: "ok", flags: parsed, args };
|
|
340
|
+
}
|