@tablation/crew 0.89.0 → 0.90.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.
Files changed (2) hide show
  1. package/dist/cli.js +194 -29
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -139,6 +139,89 @@ async function periscopesForStatus(stateDir, probe = portListening) {
139
139
  }));
140
140
  }
141
141
 
142
+ // src/shell-init.ts
143
+ var SHELLS = ["zsh", "bash", "fish"];
144
+ function shellFor(requested, envShell) {
145
+ const pick = (requested ?? envShell ?? "").split("/").pop() ?? "";
146
+ return SHELLS.includes(pick) ? pick : "zsh";
147
+ }
148
+ var POSIX = String.raw`# crew shell-init: "periscope up" lands in the worktree, "periscope down" returns to the checkout.
149
+ periscope() {
150
+ case "${"$"}{1-}" in
151
+ up|start)
152
+ shift
153
+ local _out _wt _url _noopen=
154
+ _out=$(mktemp) || return 1
155
+ case " $* " in *" --no-open "*) _noopen=1 ;; esac
156
+ command crew periscope up --json "$@" 2>&1 >"$_out" \
157
+ | command sed -e 's/^{.*"message":"\([^"]*\)".*}$/ \1/' >&2
158
+ if command grep -q '"event":"done"' "$_out"; then
159
+ _wt=$(command sed -n 's/.*"worktree":"\([^"]*\)".*/\1/p' "$_out" | command tail -n 1)
160
+ _url=$(command sed -n 's/.*"url":"\([^"]*\)".*/\1/p' "$_out" | command tail -n 1)
161
+ fi
162
+ command rm -f "$_out"
163
+ [ -n "$_wt" ] && [ -d "$_wt" ] || return 1
164
+ builtin cd "$_wt" || return 1
165
+ printf '%s\n%s\n' "$_url" "$_wt"
166
+ if [ -z "$_noopen" ] && [ -n "$_url" ]; then
167
+ if command -v open >/dev/null 2>&1; then command open "$_url"
168
+ elif command -v xdg-open >/dev/null 2>&1; then command xdg-open "$_url" >/dev/null 2>&1; fi
169
+ fi
170
+ ;;
171
+ down|end)
172
+ local _common _checkout=
173
+ _common=$(command git rev-parse --path-format=absolute --git-common-dir 2>/dev/null) && _checkout=${"$"}{_common%/.git}
174
+ command crew periscope "$@" || return $?
175
+ if [ -n "$_checkout" ] && [ -d "$_checkout" ]; then builtin cd "$_checkout" || return 1; fi
176
+ ;;
177
+ *)
178
+ command crew periscope "$@"
179
+ ;;
180
+ esac
181
+ }
182
+ `;
183
+ var FISH = String.raw`# crew shell-init: "periscope up" lands in the worktree, "periscope down" returns to the checkout.
184
+ function periscope
185
+ switch "$argv[1]"
186
+ case up start
187
+ set -e argv[1]
188
+ set -l out (mktemp); or return 1
189
+ command crew periscope up --json $argv 2>&1 >$out \
190
+ | command sed -e 's/^{.*"message":"\([^"]*\)".*}$/ \1/' >&2
191
+ set -l wt
192
+ set -l url
193
+ if command grep -q '"event":"done"' $out
194
+ set wt (command sed -n 's/.*"worktree":"\([^"]*\)".*/\1/p' $out | command tail -n 1)
195
+ set url (command sed -n 's/.*"url":"\([^"]*\)".*/\1/p' $out | command tail -n 1)
196
+ end
197
+ command rm -f $out
198
+ test -n "$wt" -a -d "$wt"; or return 1
199
+ builtin cd $wt; or return 1
200
+ printf '%s\n%s\n' $url $wt
201
+ if not contains -- --no-open $argv; and test -n "$url"
202
+ if command -sq open
203
+ command open $url
204
+ else if command -sq xdg-open
205
+ command xdg-open $url >/dev/null 2>&1
206
+ end
207
+ end
208
+ case down end
209
+ set -l checkout
210
+ set -l common (command git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)
211
+ and set checkout (string replace -r '/\.git$' '' -- $common)
212
+ command crew periscope $argv; or return $status
213
+ if test -n "$checkout" -a -d "$checkout"
214
+ builtin cd $checkout
215
+ end
216
+ case '*'
217
+ command crew periscope $argv
218
+ end
219
+ end
220
+ `;
221
+ function shellInitScript(shell) {
222
+ return shell === "fish" ? FISH : POSIX;
223
+ }
224
+
142
225
  // src/config.ts
143
226
  import { readFileSync as readFileSync9, existsSync as existsSync8 } from "fs";
144
227
  import { resolve, dirname as dirname3, isAbsolute, join as join10, basename as basename3 } from "path";
@@ -2778,12 +2861,25 @@ function parseOneRoute(c, where, file, base, stateDir, raw, missing, crewHome, t
2778
2861
  return result;
2779
2862
  }
2780
2863
  function routeForDir(cfg, dir) {
2781
- const target = resolve(dir);
2782
- const isInside = (base) => {
2864
+ return locateDir(cfg, dir)?.route;
2865
+ }
2866
+ function locateDir(cfg, dir, commonDirOf) {
2867
+ const within = (base, target2) => {
2783
2868
  const b = resolve(base);
2784
- return target === b || target.startsWith(`${b}/`);
2869
+ return target2 === b || target2.startsWith(`${b}/`);
2870
+ };
2871
+ const find = (target2) => {
2872
+ for (const route of cfg.routes) {
2873
+ const repo = reposOf(route).find((x) => within(x.dir, target2));
2874
+ if (repo) return { route, repo, checkout: resolve(repo.dir) };
2875
+ }
2876
+ return void 0;
2785
2877
  };
2786
- return cfg.routes.find((r) => reposOf(r).some((repo) => isInside(repo.dir)));
2878
+ const target = resolve(dir);
2879
+ const direct = find(target);
2880
+ if (direct) return direct;
2881
+ const common = commonDirOf?.(target);
2882
+ return common ? find(dirname3(resolve(target, common))) : void 0;
2787
2883
  }
2788
2884
  function findRoute(cfg, route) {
2789
2885
  if (!route) {
@@ -9844,6 +9940,7 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([
9844
9940
  "topic",
9845
9941
  "assignee",
9846
9942
  "issue",
9943
+ "shell",
9847
9944
  "resume"
9848
9945
  ]);
9849
9946
  function positionalArgs(argv2) {
@@ -16564,9 +16661,9 @@ function readPeriscopePrior(stateDir, ticketRecordId) {
16564
16661
  var DEFAULT_MAX_PERISCOPES2 = 3;
16565
16662
  var DEFAULT_PERISCOPE_IDLE_MINUTES2 = 120;
16566
16663
  function parsePeriscopeUpArgs(positional2, flags) {
16567
- const usage2 = "usage: crew periscope up [route] REPO [--topic TEXT | --issue NNN] [--isolate] [--host HOST] [--assignee CREW_ROW_ID] [--json]";
16664
+ const usage2 = "usage: crew periscope up [[route] REPO] [--topic TEXT | --issue NNN] [--isolate] [--host HOST] [--assignee CREW_ROW_ID] [--no-open] [--json]";
16568
16665
  const rest = positional2.slice(2);
16569
- if (rest.length === 0 || rest.length > 2) throw new PeriscopeError(usage2);
16666
+ if (rest.length > 2) throw new PeriscopeError(usage2);
16570
16667
  const topic = flags.topic?.trim();
16571
16668
  let issue;
16572
16669
  if (flags.issue !== void 0) {
@@ -16579,7 +16676,7 @@ ${usage2}`);
16579
16676
  }
16580
16677
  return {
16581
16678
  ...rest.length === 2 ? { route: rest[0] } : {},
16582
- repo: rest[rest.length - 1],
16679
+ ...rest.length > 0 ? { repo: rest[rest.length - 1] } : {},
16583
16680
  ...topic ? { topic } : {},
16584
16681
  ...issue ? { issue } : {},
16585
16682
  isolate: flags.isolate,
@@ -16618,6 +16715,25 @@ async function existingRefusal(e, t) {
16618
16715
  return void 0;
16619
16716
  }
16620
16717
  var tail = (s, n = 20) => s.trimEnd().split("\n").slice(-n).join("\n");
16718
+ function defaultTopic(repo, now) {
16719
+ return `${repo}, ${now.toISOString().slice(0, 10)}`;
16720
+ }
16721
+ function openerFor(platform2) {
16722
+ return platform2 === "darwin" ? "open" : platform2 === "linux" ? "xdg-open" : void 0;
16723
+ }
16724
+ function upOutput(res, terminal) {
16725
+ return terminal ? `${res.ticket}
16726
+ ${res.url}
16727
+ ${res.worktree}
16728
+ ` : `${res.ticket}
16729
+ ${res.worktree}
16730
+ ${res.url}
16731
+ `;
16732
+ }
16733
+ function shouldOpen(f) {
16734
+ if (f.noOpen || f.json) return false;
16735
+ return f.open || f.terminal;
16736
+ }
16621
16737
  function progressComment(url, handoff) {
16622
16738
  return [
16623
16739
  `Periscope is up: ${url}`,
@@ -16654,8 +16770,7 @@ async function runPeriscopeUp(d, args) {
16654
16770
  draft = prior;
16655
16771
  d.say("held", `holding ${prior.tag} (status ${prior.status} stays)`, { ticket: prior.tag, ticketRecordId: prior.id });
16656
16772
  } else {
16657
- const topic = args.topic ?? (await d.askTopic())?.trim();
16658
- if (!topic) throw new PeriscopeError("a topic is required: pass --topic TEXT");
16773
+ const topic = args.topic ?? (await d.askTopic())?.trim() ?? defaultTopic(args.repo, d.now());
16659
16774
  const unsafe = d.baseProblem();
16660
16775
  if (unsafe) throw new PeriscopeError(`DO NOT CUT A WORKTREE FROM THIS BASE \u2014 ${unsafe}`);
16661
16776
  draft = await d.fileDraft(`Periscope: ${topic}`);
@@ -16755,22 +16870,25 @@ ${tail(preview.output)}`);
16755
16870
  };
16756
16871
  }
16757
16872
  function parsePeriscopeDownArgs(positional2, flags) {
16758
- const usage2 = "usage: crew periscope down [route] NNN --keep|--discard [--allow-empty] [--json]";
16873
+ const usage2 = "usage: crew periscope down [[route] NNN] --keep|--discard [--allow-empty] [--json]";
16759
16874
  const rest = positional2.slice(2);
16760
- if (rest.length === 0 || rest.length > 2) throw new PeriscopeError(usage2);
16875
+ if (rest.length > 2) throw new PeriscopeError(usage2);
16761
16876
  if (flags.keep === flags.discard) throw new PeriscopeError(`pass exactly one of --keep or --discard
16762
16877
  ${usage2}`);
16763
- const m = /^(?:[A-Za-z]+-)?(\d+)$/.exec(rest[rest.length - 1]);
16764
- if (!m) throw new PeriscopeError(`"${rest[rest.length - 1]}" is not a ticket number
16878
+ const m = rest.length === 0 ? null : /^(?:[A-Za-z]+-)?(\d+)$/.exec(rest[rest.length - 1]);
16879
+ if (rest.length > 0 && !m) throw new PeriscopeError(`"${rest[rest.length - 1]}" is not a ticket number
16765
16880
  ${usage2}`);
16766
16881
  return {
16767
16882
  ...rest.length === 2 ? { route: rest[0] } : {},
16768
- number: m[1],
16883
+ ...m ? { number: m[1] } : {},
16769
16884
  mode: flags.keep ? "keep" : "discard",
16770
16885
  allowEmpty: flags.allowEmpty,
16771
16886
  json: flags.json
16772
16887
  };
16773
16888
  }
16889
+ function rowForCwd(rows, cwd) {
16890
+ return rows.find((r) => r.state === "live" && r.worktree !== "" && (cwd === r.worktree || cwd.startsWith(`${r.worktree}/`)));
16891
+ }
16774
16892
  var PROTOTYPE_HEADING = "## Prototype";
16775
16893
  function prototypeSection(files) {
16776
16894
  const added = files.reduce((n, f) => n + f.added, 0);
@@ -18509,12 +18627,13 @@ function usage(problem, requested = false) {
18509
18627
  crew reap [route] kill orphaned servers, drop worktrees for closed tickets
18510
18628
  crew drop [route] NNN remove a merged ticket's worktree and branch
18511
18629
  crew rotate-passenger-url [route] force a Host Passengers tunnel to reconnect with a fresh public URL
18512
- crew periscope up [route] REPO --topic TEXT|--issue NNN [--isolate] [--host H] [--assignee ID] [--dry-run] [--json]
18630
+ crew periscope up [[route] REPO] [--topic TEXT|--issue NNN] [--isolate] [--host H] [--assignee ID] [--no-open] [--open] [--dry-run] [--json]
18513
18631
  crew periscope up --resume NNN restart the preview an idle periscope's server was stopped for
18514
18632
  file a draft ticket, cut and prepare its worktree, start the repo's preview hook and record a Periscopes row (alias: start)
18515
- crew periscope down [route] NNN --keep|--discard [--allow-empty] [--json]
18633
+ crew periscope down [[route] NNN] --keep|--discard [--allow-empty] [--json]
18516
18634
  end a periscope: commit and push its branch (ticket back to new), or throw it away (alias: end)
18517
18635
  crew periscope list [route] [--json] the live periscopes on this ship, their idle minutes and whether each server is listening
18636
+ crew shell-init [--shell zsh|bash|fish] print the periscope shell function (install: eval "$(crew shell-init)")
18518
18637
  crew worktree [route] NNN cut (or refresh) this run's worktree for a ticket from the remote; prints its path
18519
18638
  crew unassign [route] NNN hand back a session's ticket \u2014 clears assignee, next cycle picks it up
18520
18639
  crew sync [route] fast-forward the checkout and its worktrees from the remote
@@ -18620,6 +18739,7 @@ var KNOWN_COMMANDS = /* @__PURE__ */ new Set([
18620
18739
  "run",
18621
18740
  "sample-ticket",
18622
18741
  "selftest",
18742
+ "shell-init",
18623
18743
  "ship",
18624
18744
  "skills",
18625
18745
  "status",
@@ -18631,6 +18751,16 @@ var KNOWN_COMMANDS = /* @__PURE__ */ new Set([
18631
18751
  "worktree"
18632
18752
  ]);
18633
18753
  if (!KNOWN_COMMANDS.has(command)) usage(`unknown command "${command}"`);
18754
+ if (command === "shell-init") {
18755
+ const asked = value("shell");
18756
+ if (asked !== void 0 && !SHELLS.includes(asked)) {
18757
+ process.stderr.write(`crew shell-init: unknown shell "${asked}" (have: ${SHELLS.join(", ")})
18758
+ `);
18759
+ process.exit(2);
18760
+ }
18761
+ process.stdout.write(shellInitScript(shellFor(asked, process.env.SHELL)));
18762
+ process.exit(0);
18763
+ }
18634
18764
  function runReposCheck(dir, ship) {
18635
18765
  const report = checkRepoSpec(dir, { ship, strict: flag("strict") });
18636
18766
  if (flag("json")) {
@@ -21956,8 +22086,8 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21956
22086
  case "periscope": {
21957
22087
  const subP = positional[1];
21958
22088
  if (subP !== "up" && subP !== "start" && subP !== "down" && subP !== "end" && subP !== "list") {
21959
- process.stderr.write("crew periscope up [route] REPO --topic TEXT|--issue NNN [--isolate] [--host H] [--assignee ID] [--dry-run] [--json]\n");
21960
- process.stderr.write("crew periscope down [route] NNN --keep|--discard [--allow-empty] [--json]\n");
22089
+ process.stderr.write("crew periscope up [[route] REPO] [--topic TEXT|--issue NNN] [--isolate] [--host H] [--assignee ID] [--no-open] [--open] [--dry-run] [--json]\n");
22090
+ process.stderr.write("crew periscope down [[route] NNN] --keep|--discard [--allow-empty] [--json]\n");
21961
22091
  process.stderr.write("crew periscope list [route] [--json]\n");
21962
22092
  process.exit(2);
21963
22093
  }
@@ -22002,11 +22132,27 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
22002
22132
  `);
22003
22133
  break;
22004
22134
  }
22005
- const a = downArgs;
22135
+ const real = (p) => {
22136
+ try {
22137
+ return realpathSync3(p);
22138
+ } catch {
22139
+ return p;
22140
+ }
22141
+ };
22142
+ const gathered = await Promise.all(routesP.map(async (rt) => ({ rt, rows: await gather(rt) })));
22143
+ const number = downArgs.number ?? (() => {
22144
+ const here = real(process.cwd());
22145
+ for (const g of gathered) {
22146
+ const hit = rowForCwd(g.rows.map((r) => ({ ...r, worktree: real(r.worktree) })), here);
22147
+ if (hit) return hit.number;
22148
+ }
22149
+ throw new PeriscopeError("not inside a periscope worktree \u2014 name the ticket: crew periscope down NNN --keep|--discard");
22150
+ })();
22151
+ const a = { ...downArgs, number };
22006
22152
  const hits = [];
22007
- for (const rt of routesP) {
22008
- const rows = (await gather(rt)).filter((r) => r.number === a.number);
22009
- if (rows.length) hits.push({ rt, rows });
22153
+ for (const g of gathered) {
22154
+ const rows = g.rows.filter((r) => r.number === a.number);
22155
+ if (rows.length) hits.push({ rt: g.rt, rows });
22010
22156
  }
22011
22157
  if (hits.length !== 1) {
22012
22158
  throw new PeriscopeError(hits.length === 0 ? `no periscope for ticket ${a.number} on this ship` : `ticket ${a.number} has periscopes on more than one route (${hits.map((h) => h.rt.route).join(", ")}) \u2014 name the route`);
@@ -22085,6 +22231,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
22085
22231
  say("dry-run", `would end the periscope for ${rowsD[0].ticket} (${a.mode})`);
22086
22232
  break;
22087
22233
  }
22234
+ process.chdir(repoD.dir);
22088
22235
  const res = await runPeriscopeDown(depsD, a);
22089
22236
  if (jsonP) process.stdout.write(`${JSON.stringify({ event: "done", ...res })}
22090
22237
  `);
@@ -22142,7 +22289,19 @@ ${resumed.url}
22142
22289
  `);
22143
22290
  break;
22144
22291
  }
22145
- const a = parsePeriscopeUpArgs(positional, { topic: value("topic"), issue: value("issue"), host: value("host"), assignee: value("assignee"), isolate: flag("isolate"), json: jsonP });
22292
+ const parsedUp = parsePeriscopeUpArgs(positional, { topic: value("topic"), issue: value("issue"), host: value("host"), assignee: value("assignee"), isolate: flag("isolate"), json: jsonP });
22293
+ let a;
22294
+ if (parsedUp.repo !== void 0) a = { ...parsedUp, repo: parsedUp.repo };
22295
+ else {
22296
+ const here = locateDir(cfg, process.cwd(), (d) => gitOk(d, ["rev-parse", "--path-format=absolute", "--git-common-dir"]) ?? void 0);
22297
+ if (!here) {
22298
+ const where = cfg.routes.flatMap((r) => reposOf(r).map((x) => ` ${x.name}: ${x.dir}`));
22299
+ throw new PeriscopeError(`run it from inside a configured checkout, or name the repository (crew periscope up REPO). Configured checkouts:
22300
+ ${where.join("\n")}`);
22301
+ }
22302
+ a = { ...parsedUp, repo: here.repo.name, route: parsedUp.route ?? here.route.route };
22303
+ }
22304
+ const terminal = !!process.stdin.isTTY && !!process.stdout.isTTY && !jsonP;
22146
22305
  const candidatesP = a.route ? [findRoute(cfg, a.route)] : cfg.routes.filter((r) => r.enabled);
22147
22306
  const servedP = [];
22148
22307
  for (const cand of candidatesP) {
@@ -22174,10 +22333,11 @@ ${resumed.url}
22174
22333
  return h.unsafe ? h.detail : void 0;
22175
22334
  },
22176
22335
  askTopic: async () => {
22177
- if (!process.stdin.isTTY || jsonP) return void 0;
22336
+ if (!terminal) return void 0;
22178
22337
  const rl = createInterface({ input: process.stdin, output: process.stderr });
22179
22338
  try {
22180
- return await rl.question("Periscope topic: ");
22339
+ const answer = (await rl.question(`Periscope topic for ${a.repo} (blank for "${defaultTopic(a.repo, /* @__PURE__ */ new Date())}"): `)).trim();
22340
+ return answer || void 0;
22181
22341
  } finally {
22182
22342
  rl.close();
22183
22343
  }
@@ -22283,16 +22443,21 @@ ${resumed.url}
22283
22443
  say("dry-run", `would hold ${t.tag} (status ${t.status} stays) for ${pairId}, cut or reuse its worktree for ${a.repo}, run setup${a.isolate ? ", isolate" : ""}, ports, handoff and preview on ${deps.host}, and record a Periscopes row`);
22284
22444
  break;
22285
22445
  }
22286
- say("dry-run", `would file "Periscope: ${a.topic ?? "<topic>"}" as ${cP.statuses.draft} for ${pairId}, cut a worktree for ${a.repo}, run setup${a.isolate ? ", isolate" : ""}, ports, handoff and preview on ${deps.host}, and record a Periscopes row (expires after ${caps.idleMinutes} minutes idle)`);
22446
+ say("dry-run", `would file "Periscope: ${a.topic ?? defaultTopic(a.repo, /* @__PURE__ */ new Date())}" as ${cP.statuses.draft} for ${pairId}, cut a worktree for ${a.repo}, run setup${a.isolate ? ", isolate" : ""}, ports, handoff and preview on ${deps.host}, and record a Periscopes row (expires after ${caps.idleMinutes} minutes idle)`);
22287
22447
  break;
22288
22448
  }
22289
22449
  const res = await runPeriscopeUp(deps, a);
22290
22450
  if (jsonP) process.stdout.write(`${JSON.stringify({ event: "done", ...res })}
22291
22451
  `);
22292
- else process.stdout.write(`${res.ticket}
22293
- ${res.worktree}
22294
- ${res.url}
22452
+ else {
22453
+ process.stdout.write(upOutput(res, terminal));
22454
+ const opener = openerFor(process.platform);
22455
+ if (opener && shouldOpen({ open: flag("open"), noOpen: flag("no-open"), json: jsonP, terminal })) {
22456
+ const r = spawnSync7(opener, [res.url], { stdio: "ignore" });
22457
+ if (r.error || r.status !== 0) process.stderr.write(`crew periscope: could not open ${res.url} with ${opener}
22295
22458
  `);
22459
+ }
22460
+ }
22296
22461
  } catch (e) {
22297
22462
  if (e instanceof PeriscopeError || e instanceof ConfigError) {
22298
22463
  process.stderr.write(`crew periscope: ${e.message}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.89.0",
3
+ "version": "0.90.0",
4
4
  "description": "A standing team of headless agents that picks work off a Tablation board, does it on a machine you control, and reports back.",
5
5
  "type": "module",
6
6
  "license": "MIT",