@trim21/personal-pi-extensions 0.0.209 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.0.209",
3
+ "version": "0.0.210",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -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
- pi.registerCommand("bwrap", {
715
+ const BWRAP_SPEC = {
716
+ name: "bwrap",
717
+ usage: "",
715
718
  description: "Show bwrap sandbox configuration",
716
- handler: (_args, ctx) => {
717
- const r = getResolved(ctx.hasUI);
718
- if (!r.bwrapEnabled) {
719
- ctx.ui.notify(`bwrap disabled (mode: ${r.mode})`, "info");
720
- return Promise.resolve();
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
- const net = r.network ? "net" : "no-net";
724
- const w = r.writablePaths.map((p) => resolvePath(p, localCwd));
725
- const t = r.tmpfsPaths.map((p) => resolvePath(p, localCwd));
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
- ctx.ui.notify(
728
- `bwrap ${r.mode} ${net} write:[${w.join(", ")}] tmpfs:[${t.join(", ") || "-"}]`,
729
- "info",
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: "Disable bwrap sandbox, full access",
764
- handler: (_args, ctx) => Promise.resolve(switchMode("allow-all", ctx)),
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: "Sandbox on, network off, workspace writable",
769
- handler: (_args, ctx) => Promise.resolve(switchMode("workspace-write", ctx)),
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: "Sandbox on, network on, workspace writable",
774
- handler: (_args, ctx) => Promise.resolve(switchMode("allow-net", ctx)),
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: "Sandbox on, network off, no writes",
779
- handler: (_args, ctx) => Promise.resolve(switchMode("readonly", ctx)),
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
  }
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
+ }
package/src/talk/index.ts CHANGED
@@ -16,9 +16,9 @@ import { fileURLToPath } from "node:url";
16
16
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
17
17
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
18
18
  import { Box, Text } from "@earendil-works/pi-tui";
19
- import { Type } from "typebox";
19
+ import { type TObject, Type } from "typebox";
20
20
 
21
- import { parseArgs } from "../lib/cli-args.js";
21
+ import { type CommandResult, type CommandSpec, parseCommand } from "../lib/cli.js";
22
22
  import { resolveHomePath } from "../lib/path.js";
23
23
  import { TalkCore } from "./core.js";
24
24
  import { formatDelivery } from "./format.js";
@@ -281,92 +281,155 @@ export default function talk(pi: ExtensionAPI) {
281
281
 
282
282
  // ── /talk commands ────────────────────────────────────────────────────
283
283
 
284
- pi.registerCommand("talk", {
285
- description: "List registered pi agents",
286
- async handler() {
287
- const text = requireInit() ?? (await core.list());
284
+ type OkResult<TFlags extends TObject> = Extract<CommandResult<TFlags>, { kind: "ok" }>;
285
+
286
+ /** Parse a /talk command; on help/error or init failure the text is sent, otherwise run() produces the listing text. */
287
+ function handleCommand<TFlags extends TObject>(
288
+ spec: CommandSpec<TFlags>,
289
+ args: string,
290
+ run: (parsed: OkResult<TFlags>) => Promise<string> | string,
291
+ ): Promise<void> {
292
+ const parsed = parseCommand(spec, args);
293
+ if (parsed.kind !== "ok") {
294
+ pi.sendMessage({ customType: LIST_TYPE, content: parsed.text, display: true });
295
+ return Promise.resolve();
296
+ }
297
+ return (async () => {
298
+ const initError = requireInit();
299
+ const text = initError ?? (await run(parsed));
288
300
  pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
289
- },
290
- });
301
+ })();
302
+ }
291
303
 
292
- pi.registerCommand("talk-dead", {
304
+ const TALK_SPEC = {
305
+ name: "talk",
306
+ usage: "",
307
+ description: "List registered pi agents",
308
+ flags: Type.Object({}),
309
+ arity: { max: 0 },
310
+ };
311
+
312
+ const TALK_DEAD_SPEC = {
313
+ name: "talk-dead",
314
+ usage: "[agentId] [options]",
293
315
  description:
294
316
  "Mark a talk agent as dead (shown offline, swept soon): no arg = this agent, <agentId> = that agent, --all = every other visible agent",
295
- async handler(args) {
296
- const initError = requireInit();
297
- const trimmed = args.trim();
298
- const text =
299
- initError ??
300
- (trimmed === "--all"
317
+ flags: Type.Object({
318
+ all: Type.Optional(Type.Boolean({ description: "Mark every other visible agent dead" })),
319
+ }),
320
+ flagMeta: { all: { short: "a" } },
321
+ arity: { max: 1 },
322
+ };
323
+
324
+ const TALK_GROUP_JOIN_SPEC = {
325
+ name: "talk-group-join",
326
+ usage: "[group name] [options]",
327
+ description:
328
+ "Join or create a private agent group (members see only each other; an agent in no group sees only itself). No arg = new group with a generated uuid; <name> = join that group, or create it when it does not exist; --name <alias> additionally sets this agent's display name",
329
+ flags: Type.Object({
330
+ name: Type.Optional(Type.String({ description: "Set this agent's display name" })),
331
+ }),
332
+ flagMeta: { name: { short: "n", valuePlaceholder: "<alias>" } },
333
+ arity: { max: 1 },
334
+ examples: ["/talk-group-join frontend", "/talk-group-join --name frontend"],
335
+ };
336
+
337
+ const TALK_GROUP_JOIN_LAST_SPEC = {
338
+ name: "talk-group-join-last",
339
+ usage: "",
340
+ description: "Join the most recently created agent group (no-op when already in it).",
341
+ flags: Type.Object({}),
342
+ arity: { max: 0 },
343
+ };
344
+
345
+ const TALK_GROUP_LEAVE_SPEC = {
346
+ name: "talk-group-leave",
347
+ usage: "",
348
+ description: "Leave the current agent group (an emptied group is deleted).",
349
+ flags: Type.Object({}),
350
+ arity: { max: 0 },
351
+ };
352
+
353
+ const TALK_GROUP_LIST_SPEC = {
354
+ name: "talk-group-list",
355
+ usage: "",
356
+ description: "List all agent groups and their members, newest first.",
357
+ flags: Type.Object({}),
358
+ arity: { max: 0 },
359
+ };
360
+
361
+ const TALK_GROUP_DEL_SPEC = {
362
+ name: "talk-group-del",
363
+ usage: "<group name>",
364
+ description:
365
+ "Delete an agent group by name; its members become ungrouped (see only themselves).",
366
+ flags: Type.Object({}),
367
+ arity: { min: 1, max: 1 },
368
+ };
369
+
370
+ const TALK_GROUP_CLEAR_SPEC = {
371
+ name: "talk-group-clear",
372
+ usage: "",
373
+ description: "Delete every agent group; all agents become ungrouped.",
374
+ flags: Type.Object({}),
375
+ arity: { max: 0 },
376
+ };
377
+
378
+ pi.registerCommand("talk", {
379
+ description: TALK_SPEC.description,
380
+ handler: (args) => handleCommand(TALK_SPEC, args, async () => core.list()),
381
+ });
382
+
383
+ pi.registerCommand("talk-dead", {
384
+ description: TALK_DEAD_SPEC.description,
385
+ handler: (args) =>
386
+ handleCommand(TALK_DEAD_SPEC, args, async (parsed) => {
387
+ if (parsed.flags.all && parsed.args.length > 0) {
388
+ return "--all cannot be combined with an agent id.\nTry '/talk-dead --help' for usage.";
389
+ }
390
+ return parsed.flags.all
301
391
  ? await core.markAllDead()
302
- : trimmed
303
- ? await core.markDead(trimmed)
304
- : await core.markDead());
305
- pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
306
- },
392
+ : parsed.args[0]
393
+ ? await core.markDead(parsed.args[0])
394
+ : await core.markDead();
395
+ }),
307
396
  });
308
397
 
309
398
  pi.registerCommand("talk-group-join", {
310
- description:
311
- "Join or create a private agent group (members see only each other; an agent in no group sees only itself). No arg = new group with a generated uuid; <name> = join that group, or create it when it does not exist; --name <alias> additionally sets this agent's display name (e.g. --name frontend)",
312
- async handler(args) {
313
- const initError = requireInit();
314
- const parsed = parseArgs(args);
315
- const groupName = parsed.positionals[0];
316
- const flag = parsed.flags.name;
317
- const agentName = typeof flag === "string" && flag.trim() !== "" ? flag.trim() : undefined;
318
- if (agentName !== undefined) explicitName = agentName;
319
- const text = initError ?? (await core.groupJoin(groupName || undefined, agentName));
320
- pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
321
- },
399
+ description: TALK_GROUP_JOIN_SPEC.description,
400
+ handler: (args) =>
401
+ handleCommand(TALK_GROUP_JOIN_SPEC, args, async (parsed) => {
402
+ const agentName = parsed.flags.name?.trim() || undefined;
403
+ if (agentName !== undefined) explicitName = agentName;
404
+ return core.groupJoin(parsed.args[0], agentName);
405
+ }),
322
406
  });
323
407
 
324
408
  pi.registerCommand("talk-group-join-last", {
325
- description: "Join the most recently created agent group (no-op when already in it).",
326
- async handler() {
327
- const initError = requireInit();
328
- const text = initError ?? (await core.groupJoinLast());
329
- pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
330
- },
409
+ description: TALK_GROUP_JOIN_LAST_SPEC.description,
410
+ handler: (args) =>
411
+ handleCommand(TALK_GROUP_JOIN_LAST_SPEC, args, async () => core.groupJoinLast()),
331
412
  });
332
413
 
333
414
  pi.registerCommand("talk-group-leave", {
334
- description: "Leave the current agent group (an emptied group is deleted).",
335
- async handler() {
336
- const initError = requireInit();
337
- const text = initError ?? (await core.groupLeave());
338
- pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
339
- },
415
+ description: TALK_GROUP_LEAVE_SPEC.description,
416
+ handler: (args) => handleCommand(TALK_GROUP_LEAVE_SPEC, args, async () => core.groupLeave()),
340
417
  });
341
418
 
342
419
  pi.registerCommand("talk-group-list", {
343
- description: "List all agent groups and their members, newest first.",
344
- async handler() {
345
- const initError = requireInit();
346
- const text = initError ?? (await core.groupList());
347
- pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
348
- },
420
+ description: TALK_GROUP_LIST_SPEC.description,
421
+ handler: (args) => handleCommand(TALK_GROUP_LIST_SPEC, args, async () => core.groupList()),
349
422
  });
350
423
 
351
424
  pi.registerCommand("talk-group-del", {
352
- description:
353
- "Delete an agent group by name; its members become ungrouped (see only themselves).",
354
- async handler(args) {
355
- const initError = requireInit();
356
- const name = args.trim();
357
- const text =
358
- initError ?? (name ? await core.groupDelete(name) : "Usage: /talk-group-del <group name>");
359
- pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
360
- },
425
+ description: TALK_GROUP_DEL_SPEC.description,
426
+ handler: (args) =>
427
+ handleCommand(TALK_GROUP_DEL_SPEC, args, async (parsed) => core.groupDelete(parsed.args[0])),
361
428
  });
362
429
 
363
430
  pi.registerCommand("talk-group-clear", {
364
- description: "Delete every agent group; all agents become ungrouped.",
365
- async handler() {
366
- const initError = requireInit();
367
- const text = initError ?? (await core.groupClear());
368
- pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
369
- },
431
+ description: TALK_GROUP_CLEAR_SPEC.description,
432
+ handler: (args) => handleCommand(TALK_GROUP_CLEAR_SPEC, args, async () => core.groupClear()),
370
433
  });
371
434
 
372
435
  // ── Delivery card ──────────────────────────────────────────────────────