@trim21/personal-pi-extensions 0.0.206 → 0.0.209

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/README.md CHANGED
@@ -222,7 +222,7 @@ session 间消息传递:不同 pi session(同一台机器)通过一个共
222
222
 
223
223
  ```
224
224
  storage.ts —— 存储层:TalkStorage 接口 + SqliteTalkStorage 实现(node:sqlite,零 npm 依赖)
225
- core.ts —— talk 核心:registry/mailbox/policy/format + TalkCore 协调器,只依赖存储层,通过回调 yield 投递/通知
225
+ core.ts —— talk 核心:registry/mailbox/group/policy/format + TalkCore 协调器,只依赖存储层,通过回调 yield 投递/通知
226
226
  index.ts —— pi adapter:把 core 接到 pi 的 sendMessage / 生命周期事件 / 工具注册
227
227
  ```
228
228
 
@@ -230,18 +230,18 @@ index.ts —— pi adapter:把 core 接到 pi 的 sendMessage / 生命周
230
230
 
231
231
  ### 工具(LLM 可见)
232
232
 
233
- | 工具 | 作用 |
234
- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
235
- | `talk-list-sessions` | 列出会话,返回 JSON 数组(`status` / `work_dir` / `id` / `name`,自己带 `self: true`);始终列出所有可见会话,`status` 区分 live(`idle` / `working` / `waiting-talk-message`)与 `offline` |
236
- | `talk-ask` | 向某个 session 提问并阻塞等待回复(默认 30 分钟超时) |
237
- | `talk-send` | 发送纯文本消息到单个 session(`to` 只接受明确的 session id,不支持广播) |
238
- | `talk-reply` | 回复一个 ask(`replyTo` 为 ask id,显式关联、不推断) |
233
+ | 工具 | 作用 |
234
+ | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
235
+ | `talk-list-sessions` | 列出会话,返回 JSON 数组(`status` / `work_dir` / `id` / `name`,自己带 `self: true`);只列出同组成员(未入组时只有自己),`status` 区分 live(`idle` / `working` / `waiting-talk-message`)与 `offline` |
236
+ | `talk-ask` | 向某个 session 提问并阻塞等待回复(默认 30 分钟超时) |
237
+ | `talk-send` | 发送纯文本消息到单个 session(`to` 只接受明确的 session id,不支持广播) |
238
+ | `talk-reply` | 回复一个 ask(`replyTo` 为 ask id,显式关联、不推断) |
239
239
 
240
240
  对端消息自动投递(无需主动拉取):投递方式由 `talk.deliver` 配置,`steer` 在模型工作过程中打断/唤醒,`queue` 排队到 session 下一轮自然 turn 时注入。
241
241
 
242
242
  **定位只认 session id**:`talk-send` / `talk-ask` / `talk-watch` 的 `to` 只接受 `talk-list-sessions` 返回的 `id`(pi 的 session uuid)精确匹配,不做 name/路径/前缀匹配。
243
243
 
244
- **标记废弃 session**:`/talk-dead` 给 session 打 `offline` 标志并把 `lastSeenAt` 置 0(列表显示为 offline,下次 sweep 无 mail 即回收):无参标记当前 session,`/talk-dead <sessionId>` 标记指定 session,`/talk-dead --all` 标记所有其他可见 session
244
+ **标记废弃 session**:`/talk-dead` 给 session 打 `offline` 标志并把 `lastSeenAt` 置 0(列表显示为 offline,下次 sweep 无 mail 即回收):无参标记当前 session,`/talk-dead <sessionId>` 标记指定 session,`/talk-dead --all` 标记所有其他可见 session(同组成员)。
245
245
 
246
246
  ### 关键设计
247
247
 
@@ -251,7 +251,7 @@ index.ts —— pi adapter:把 core 接到 pi 的 sendMessage / 生命周
251
251
  - **双向 ask 仲裁**:`talk-ask` 发起前先检查收件箱(有对方消息就先读/先回);阻塞等待期间若收到对方的 ask(而非 reply),按两个 ask 的 `ts` 字段仲裁——先 ask 者主导继续等,后 ask 者让位并先回复对方。`ts` 是信件内固定字段,双方读到同一对值,结论天然对称;同毫秒碰撞用 `session dir + session id` 字符串比较兜底。
252
252
  - **typebox runtime 验证**:所有从存储读出的值经 TypeBox schema 校验,损坏/伪造数据被拒绝,不做 `as T` 强转。
253
253
  - **安全**:纯文本 ≤32KB;10s 去重 / 30s 限速 8 条 / 50 积压上限(防环);每条投递标注来源(来自另一个 pi session,非用户)。
254
- - **workspace 可见性**:每个 workspace 通过 `<cwd>/.pi/talk.json``allowed` 控制自己能看到哪些 session,见下方配置。
254
+ - **group 可见性**:可见性完全由 group 决定——组内 session 只能看到同组成员,不在任何 group session 只能看到自己。用 `/talk-group-*` 命令建组/入组,见下方「group 可见性」。
255
255
 
256
256
  ### 配置
257
257
 
@@ -277,22 +277,28 @@ sqlite 文件路径按优先级取第一个可用值:
277
277
 
278
278
  默认 `"queue"`。
279
279
 
280
- ### workspace 可见性
280
+ ### group 可见性
281
281
 
282
- 每个 workspace 通过 `<cwd>/.pi/talk.json` 的 `allowed` 决定自己能看到哪些 session:
282
+ 可见性完全由 group 决定,不再有路径/workspace 配置:
283
283
 
284
- ```jsonc
285
- // ~/projects/company1/.pi/talk.json
286
- {
287
- "allowed": ["~/projects/company1/"],
288
- }
284
+ - 在某个 group 里的 session **只能看到同组成员**;不在任何 group 的 session **只能看到自己**。
285
+ - group 是带 uuid 的私有房间:任何 session 都可以凭 uuid 加入任意 group,也可以自由离开,没有 owner。
286
+ - 一个 session 只能属于一个 group:加入新 group 自动离开旧 group。
287
+ - group 成员关系存在共享的 talk DB 里,每次 list/发送实时读取,加入/离开立即对所有 session 生效(无需重启)。
288
+
289
+ 通过 `/talk-group-*` 命令操作(TUI):
290
+
291
+ ```
292
+ /talk-group-join # 无参:自动创建一个新 group(uuid 作为组名)并加入
293
+ /talk-group-join <name> # 加入名为 name 的 group;不存在则创建(名字允许字母/数字/-/_)
294
+ /talk-group-join-last # 加入最近创建的 group(方便新开 session 快速归队)
295
+ /talk-group-leave # 离开当前 group(组空了自动删除)
296
+ /talk-group-list # 列出所有 group 及其成员,最新创建的在前
297
+ /talk-group-del <name> # 删除指定 group(成员随之变为未入组)
298
+ /talk-group-clear # 删除所有 group
289
299
  ```
290
300
 
291
- - `allowed` 是路径前缀列表:能看到前缀本身及其子目录下的 session(`~/projects/company1/*`),看不到 `~/projects/company2/` 下的 session;`company1` 不会误匹配 `company12`
292
- - 路径支持 `~` 展开,相对路径相对该 workspace 的 cwd 解析
293
- - 无 `allowed` 字段 → 全部可见;`"allowed": []` → 谁都看不到
294
- - 可见性单向生效:A 的配置只决定 A 能看到谁,不影响 B
295
- - 不可见的 session 不仅 list 不到,也无法 `talk-send` / `talk-ask` 寻址(即使知道 id 也会被拒绝)
301
+ 典型用法:在 A session 里 `/talk-group-join`(或 `/talk-group-join mytask`)建组,把组名复制到 B、C session `/talk-group-join <组名>`,此后 A/B/C 互相可见且只见彼此。
296
302
 
297
303
  | 变量 | 默认 | 含义 |
298
304
  | ----------------- | --------------------------------- | ------------------------------- |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.0.206",
3
+ "version": "0.0.209",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -31,7 +31,7 @@
31
31
  import { spawn } from "node:child_process";
32
32
  import { mkdir, readFile, writeFile } from "node:fs/promises";
33
33
  import { homedir } from "node:os";
34
- import { join } from "node:path";
34
+ import { dirname, join, resolve } from "node:path";
35
35
 
36
36
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
37
37
  import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
@@ -603,12 +603,24 @@ export function cleanStepOutput(stepLog: string): string {
603
603
  .trim();
604
604
  }
605
605
 
606
+ /**
607
+ * Strip terminal escape sequences and a leading UTF-8 BOM from a raw job log,
608
+ * keeping everything else — timestamps, `##[group]` markers, blank lines —
609
+ * intact. Used when writing a job's complete log to a file: complete, but
610
+ * readable without ANSI garbage.
611
+ */
612
+ export function stripAnsi(text: string): string {
613
+ return text.replace(/^\uFEFF/, "").replaceAll(ANSI_RE, "");
614
+ }
615
+
606
616
  export interface StepLogParams {
607
617
  runId: string;
608
618
  job?: string;
609
619
  step: string;
610
620
  offset?: number;
611
621
  limit?: number;
622
+ /** Return the complete, untruncated step output (ignores `offset`/`limit`). */
623
+ full?: boolean;
612
624
  }
613
625
 
614
626
  /**
@@ -623,7 +635,7 @@ export async function renderStepLog(
623
635
  fetchJobLog: (jobId: number) => Promise<string>,
624
636
  onUpdate?: (msg: CiLogsResult) => void,
625
637
  ): Promise<CiLogsResult> {
626
- const { job, step, offset, limit } = params;
638
+ const { job, step, offset, limit, full } = params;
627
639
 
628
640
  if (!job) {
629
641
  return {
@@ -707,6 +719,26 @@ export async function renderStepLog(
707
719
 
708
720
  const clean = cleanStepOutput(stepLog);
709
721
 
722
+ // `full`: return the complete output, no truncation and no offset.
723
+ if (full) {
724
+ const fullLines = clean.split("\n").length;
725
+ return {
726
+ content: [{ type: "text", text: clean }],
727
+ details: {
728
+ summary: `Step ${stepNum} — ${targetJob.name} / ${found.name}: complete output (${fullLines} lines)`,
729
+ truncated: false,
730
+ full: true,
731
+ job: {
732
+ name: targetJob.name,
733
+ conclusion: targetJob.conclusion,
734
+ steps: stepsDetail(targetJob, new Set([stepNum])),
735
+ },
736
+ totalLines: fullLines,
737
+ shownLines: fullLines,
738
+ },
739
+ };
740
+ }
741
+
710
742
  // Apply offset on the cleaned text, then truncate.
711
743
  const totalLines = clean.split("\n").length;
712
744
  let logToShow = clean;
@@ -757,6 +789,8 @@ export interface JobLogsParams {
757
789
  job?: string;
758
790
  offset?: number;
759
791
  limit?: number;
792
+ /** Expand every step's complete output (default: only failed steps, truncated). */
793
+ full?: boolean;
760
794
  }
761
795
 
762
796
  export interface JobLogsStep {
@@ -781,7 +815,7 @@ export async function renderJobLogs(
781
815
  jobs: CiLogsJob[],
782
816
  fetchJobLog: (jobId: number) => Promise<string>,
783
817
  ): Promise<CiLogsResult> {
784
- const { job, offset, limit } = params;
818
+ const { job, offset, limit, full } = params;
785
819
 
786
820
  if (!jobs || jobs.length === 0) {
787
821
  return {
@@ -814,7 +848,7 @@ export async function renderJobLogs(
814
848
  let rawLog: string | null = null;
815
849
 
816
850
  for (const s of j.steps) {
817
- if (s.conclusion !== "failure") {
851
+ if (!full && s.conclusion !== "failure") {
818
852
  steps.push({ name: s.name });
819
853
  continue;
820
854
  }
@@ -828,6 +862,13 @@ export async function renderJobLogs(
828
862
  }
829
863
 
830
864
  const clean = cleanStepOutput(stepLog);
865
+
866
+ // `full`: every step carries its complete, untruncated output.
867
+ if (full) {
868
+ steps.push({ name: s.name, output: clean });
869
+ continue;
870
+ }
871
+
831
872
  const totalLines = clean.split("\n").length;
832
873
 
833
874
  // Apply offset on the cleaned text, then truncate.
@@ -856,7 +897,7 @@ export async function renderJobLogs(
856
897
 
857
898
  const totalJobs = output.length;
858
899
  const failedJobs = output.filter((j) => j.steps.some((s) => s.output !== undefined)).length;
859
- const totalFailedSteps = output.reduce(
900
+ const expandedSteps = output.reduce(
860
901
  (acc, j) => acc + j.steps.filter((s) => s.output !== undefined).length,
861
902
  0,
862
903
  );
@@ -864,8 +905,11 @@ export async function renderJobLogs(
864
905
  return {
865
906
  content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
866
907
  details: {
867
- summary: `${totalJobs} job${totalJobs > 1 ? "s" : ""}, ${failedJobs} failed, ${totalFailedSteps} failed step${totalFailedSteps > 1 ? "s" : ""}`,
908
+ summary: full
909
+ ? `${totalJobs} job${totalJobs > 1 ? "s" : ""}, ${expandedSteps} step output${expandedSteps === 1 ? "" : "s"} expanded (full, untruncated)`
910
+ : `${totalJobs} job${totalJobs > 1 ? "s" : ""}, ${failedJobs} failed, ${expandedSteps} failed step${expandedSteps > 1 ? "s" : ""}`,
868
911
  truncated: undefined,
912
+ ...(full && { full: true }),
869
913
  jobs: targetJobs.map((j) => ({
870
914
  name: j.name,
871
915
  conclusion: j.conclusion,
@@ -875,6 +919,142 @@ export async function renderJobLogs(
875
919
  };
876
920
  }
877
921
 
922
+ // ── writing complete logs to a file ─────────────────────────────────────────
923
+
924
+ export interface WriteLogFileParams {
925
+ runId: string;
926
+ job?: string;
927
+ step?: string;
928
+ outputFile: string;
929
+ }
930
+
931
+ /**
932
+ * Write the complete log to a file and return metadata (path, line/byte
933
+ * counts) instead of the log content itself. With `step`: the step's cleaned
934
+ * output. Without `step`: the whole job's log, timestamps and `##[group]`
935
+ * markers kept but ANSI escapes stripped. `job` is required when the run has
936
+ * more than one job (a single-job run is used implicitly). Relative
937
+ * `outputFile` paths resolve against `cwd`.
938
+ */
939
+ export async function writeLogFile(
940
+ params: WriteLogFileParams,
941
+ jobs: CiLogsJob[],
942
+ fetchJobLog: (jobId: number) => Promise<string>,
943
+ cwd: string | undefined,
944
+ input: unknown,
945
+ ): Promise<CiLogsResult> {
946
+ const { job, step, outputFile } = params;
947
+
948
+ const isNumeric = /^\d+$/.test(job ?? "");
949
+ const targetJobs = job ? jobs.filter((j) => (isNumeric ? String(j.id) : j.name) === job) : jobs;
950
+ if (targetJobs.length === 0) {
951
+ return {
952
+ content: [
953
+ {
954
+ type: "text",
955
+ text: `Job "${job}" not found. Available: ${jobs.map((j) => `${j.name} (id: ${j.id})`).join(", ")}`,
956
+ },
957
+ ],
958
+ details: { input },
959
+ };
960
+ }
961
+ if (targetJobs.length > 1) {
962
+ return {
963
+ content: [
964
+ {
965
+ type: "text",
966
+ text: `Job "${job}" matches ${targetJobs.length} jobs. Specify a unique job name or id. Available: ${jobs.map((j) => `${j.name} (id: ${j.id})`).join(", ")}`,
967
+ },
968
+ ],
969
+ details: { input },
970
+ };
971
+ }
972
+ const targetJob = targetJobs[0];
973
+
974
+ if (targetJob.status === "queued") {
975
+ return {
976
+ content: [
977
+ {
978
+ type: "text",
979
+ text: `Job "${targetJob.name}" is still queued — no logs available yet. Use \`watch-github-run\` to wait for it to start, then retry.`,
980
+ },
981
+ ],
982
+ details: { input },
983
+ };
984
+ }
985
+
986
+ const rawLog = await fetchJobLog(targetJob.id);
987
+
988
+ let content: string;
989
+ let what: string;
990
+ if (step !== undefined && step !== null) {
991
+ const found = targetJob.steps.find((s) => s.name.toLowerCase() === step.toLowerCase());
992
+ if (!found) {
993
+ return {
994
+ content: [
995
+ {
996
+ type: "text",
997
+ text: `Step "${step}" not found. Available: ${targetJob.steps.map((s) => `${s.name} (${s.number})`).join(", ")}`,
998
+ },
999
+ ],
1000
+ details: { input },
1001
+ };
1002
+ }
1003
+ const stepLog = extractStepFromLog(rawLog, found.number, targetJob.steps);
1004
+ if (stepLog === null) {
1005
+ return {
1006
+ content: [
1007
+ {
1008
+ type: "text",
1009
+ text: `Could not extract step ${found.number} from job "${targetJob.name}" logs. The log may be malformed or empty.`,
1010
+ },
1011
+ ],
1012
+ details: { input },
1013
+ };
1014
+ }
1015
+ content = cleanStepOutput(stepLog);
1016
+ what = `step ${found.number} ("${found.name}") of job "${targetJob.name}"`;
1017
+ } else {
1018
+ content = stripAnsi(rawLog);
1019
+ what = `job "${targetJob.name}" (id: ${targetJob.id})`;
1020
+ }
1021
+
1022
+ const target = resolve(cwd ?? process.cwd(), outputFile);
1023
+ await mkdir(dirname(target), { recursive: true });
1024
+ await withFileMutationQueue(target, async () => {
1025
+ await writeFile(target, content);
1026
+ });
1027
+
1028
+ const lines = content.split("\n").length;
1029
+ const bytes = Buffer.byteLength(content, "utf8");
1030
+
1031
+ return {
1032
+ content: [
1033
+ {
1034
+ type: "text",
1035
+ text:
1036
+ `## CI log written to \`${target}\`\n\n` +
1037
+ `- content: ${what}\n` +
1038
+ `- ${lines} lines, ${bytes} bytes\n` +
1039
+ `- run: ${params.runId}\n\n` +
1040
+ `Read it with the \`read\` tool (use \`offset\`/\`limit\` for large files).`,
1041
+ },
1042
+ ],
1043
+ details: {
1044
+ outputFile: target,
1045
+ lines,
1046
+ bytes,
1047
+ runId: params.runId,
1048
+ job: {
1049
+ name: targetJob.name,
1050
+ id: targetJob.id,
1051
+ conclusion: targetJob.conclusion,
1052
+ },
1053
+ input,
1054
+ },
1055
+ };
1056
+ }
1057
+
878
1058
  // ── tools ────────────────────────────────────────────────────────────────────
879
1059
 
880
1060
  export default function ghReadonlyTools(pi: ExtensionAPI) {
@@ -1150,7 +1330,7 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1150
1330
  name: "read-github-ci-logs",
1151
1331
  label: "GitHub CI Logs",
1152
1332
  description:
1153
- "Get CI logs from a GitHub Actions workflow run. Without step: returns a JSON array of jobs [{name, steps:[{name, output?}]}] where every step is listed by name and failed steps carry their log as plain text in `output`. With step (requires job): returns that step's complete log as plain text. offset/limit control the size of every expanded output. Use run_id from list-github-workflow-runs. Note: queued jobs have no logs yet; use watch-github-run to wait for completion.",
1333
+ "Get CI logs from a GitHub Actions workflow run. Without step: returns a JSON array of jobs [{name, steps:[{name, output?}]}] where every step is listed by name and failed steps carry their log as plain text in `output`. With step (requires job): returns that step's complete log as plain text. offset/limit control the size of every expanded output. Use run_id from list-github-workflow-runs. Note: queued jobs have no logs yet; use watch-github-run to wait for completion. Set full=true for complete untruncated outputs (every step when step is omitted; caution: very large outputs consume a lot of LLM context). Set output_file=/path to write the complete log to a file instead of returning it (requires job when the run has multiple jobs); the tool returns the file path to read.",
1154
1334
  promptSnippet: "Read GitHub CI logs",
1155
1335
  parameters: Type.Object({
1156
1336
  run_id: Type.Union([Type.Number(), Type.String()], { description: "Workflow run ID" }),
@@ -1178,9 +1358,21 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1178
1358
  description: "Maximum number of lines per output text (default 500).",
1179
1359
  }),
1180
1360
  ),
1361
+ full: Type.Optional(
1362
+ Type.Boolean({
1363
+ description:
1364
+ "Return complete, untruncated output instead of the default 500-line/60KB cap. With `step`: that step's full output. Without `step`: every step's full output (not just failed ones). Ignored when `output_file` is set. Caution: very large outputs consume a lot of LLM context — prefer `output_file` for big logs.",
1365
+ }),
1366
+ ),
1367
+ output_file: Type.Optional(
1368
+ Type.String({
1369
+ description:
1370
+ "Write the complete log to this file instead of returning it (relative paths resolve against the working directory). With `step` (requires `job`): the step's cleaned output. Without `step`: requires `job` (or a run with a single job) and writes that job's full log — timestamps and group markers kept, ANSI escapes stripped. Returns the file path; read it with the `read` tool.",
1371
+ }),
1372
+ ),
1181
1373
  }),
1182
1374
  async execute(_id, params, signal, onUpdate, ctx) {
1183
- const { run_id, repo, job, step, offset, limit } = params;
1375
+ const { run_id, repo, job, step, offset, limit, full, output_file } = params;
1184
1376
 
1185
1377
  const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
1186
1378
  const jobsOut = await ghExec(["api", `/repos/${effectiveRepo}/actions/runs/${run_id}/jobs`], {
@@ -1193,6 +1385,17 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1193
1385
  const fetchJobLog = (jobId: number): Promise<string> =>
1194
1386
  getJobLog(String(run_id), jobId, effectiveRepo, signal, ctx.cwd, params);
1195
1387
 
1388
+ // ── Write the complete log to a file ───────────────────────────────
1389
+ if (output_file !== undefined && output_file !== null && output_file !== "") {
1390
+ return writeLogFile(
1391
+ { runId: String(run_id), job, step, outputFile: output_file },
1392
+ jobs,
1393
+ fetchJobLog,
1394
+ ctx.cwd,
1395
+ params,
1396
+ );
1397
+ }
1398
+
1196
1399
  // ── Fetch a specific step's logs (requires `job`) ─────────────────
1197
1400
  if (step !== undefined && step !== null) {
1198
1401
  onUpdate?.({
@@ -1200,7 +1403,7 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1200
1403
  details: {},
1201
1404
  });
1202
1405
  const stepResult = await renderStepLog(
1203
- { runId: String(run_id), job, step, offset, limit },
1406
+ { runId: String(run_id), job, step, offset, limit, full },
1204
1407
  jobs,
1205
1408
  fetchJobLog,
1206
1409
  onUpdate,
@@ -1214,7 +1417,7 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1214
1417
  details: {},
1215
1418
  });
1216
1419
  const jobsResult = await renderJobLogs(
1217
- { runId: String(run_id), job, offset, limit },
1420
+ { runId: String(run_id), job, offset, limit, full },
1218
1421
  jobs,
1219
1422
  fetchJobLog,
1220
1423
  );
@@ -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
+ }