@voidlabs.gg/voidshift 0.4.2 → 0.4.4

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
@@ -5,7 +5,7 @@ One CLI for the Void dev tools. Install it once, run `vsf` (short for
5
5
  defaults you can change. Everything setup does can be seen and changed later
6
6
  from the command line.
7
7
 
8
- > Early release. macOS only for now; Windows and Linux are next.
8
+ > Early release. macOS and Linux (awake is macOS only); Windows is next.
9
9
 
10
10
  ```sh
11
11
  npm i -g @voidlabs.gg/voidshift
@@ -50,15 +50,24 @@ Five agents, installed into Claude Code (and Codex if you use it):
50
50
  | QA | CI, then code review, then a usability check in a headless browser |
51
51
 
52
52
  Use them yourself (`/vs-plan ENG-123` in Claude Code), or let a **workflow**
53
- run them. A workflow starts when one of your Linear cards gets a label, runs
54
- agents in order in a project (a repo on your Mac), and sends work back to the
55
- step that made it when a check fails:
53
+ run them. A workflow starts when one of your Linear or VoidTrack cards gets a
54
+ label, or on a schedule, runs agents in order in a project (a repo on your
55
+ machine), and sends work back to the step that made it when a check fails:
56
56
 
57
57
  ```
58
58
  when ENG + vs-plan: Dev → QA in tether-relay
59
59
  ```
60
60
 
61
- The runner checks Linear every minute, works in its own copy of the repo so
61
+ Triggers, as `--trigger` takes them:
62
+
63
+ ```
64
+ ENG:vs-plan # a Linear card in ENG assigned to you gets vs-plan
65
+ voidtrack:ORG:vs-start # the same for VoidTrack (vsf agents key --voidtrack)
66
+ "cron:0 5 * * 1:Update dependencies" # every Monday 05:00, no card, on its own branch
67
+ ```
68
+
69
+ A schedule fires once when due (a missed one is made up within a day) and
70
+ `vsf agents run --workflow NAME` fires it now. The runner checks every minute, works in its own copy of the repo so
62
71
  your checkout is never touched, and keeps two labels on the card as it goes: the
63
72
  stage (`vs-planning`, `vs-plan-review`, `vs-dev`, `vs-qa`, then `vs-qa-verified`
64
73
  or `vs-parked`) and the round (`vs-v1`, `vs-v2`). On Linear it posts the plan and
@@ -104,10 +113,13 @@ to trust the recall hook once: run `/hooks` in Codex.
104
113
  - **Your own logins.** Agents run the official `claude` and `codex` CLIs you
105
114
  already use, signed in with your own account. VoidShift never reads or
106
115
  stores those credentials. The Linear API key for the runner lives in your
107
- macOS Keychain.
116
+ macOS Keychain, or on Linux in `~/.config/voidshift/agents/secrets.json`
117
+ (mode 0600).
108
118
  - **Trust.** The Dev agent can run shell commands inside its copy of the repo
109
119
  so it can install and test. Review its pull requests like any other.
110
120
  - **Your config** lives in `~/.config/voidshift`.
121
+ - **Linux.** The runner is a systemd user timer. Run
122
+ `sudo loginctl enable-linger $USER` so it keeps going after you log out.
111
123
 
112
124
  ```sh
113
125
  vsf doctor # check everything is set up, and whether an update is out
package/dist/checks.js CHANGED
@@ -19,23 +19,24 @@ function firstLine(s) {
19
19
  }
20
20
  export function baseChecks(platform, nodeVersion, exec) {
21
21
  const checks = [];
22
- checks.push(platform === 'darwin'
23
- ? { name: 'macOS', status: 'ok', detail: 'supported' }
24
- : { name: 'macOS', status: 'fail', detail: `${platform} is not supported yet`, fix: 'The MVP runs on macOS only' });
22
+ const linux = platform === 'linux';
23
+ checks.push(platform === 'darwin' || linux
24
+ ? { name: 'platform', status: 'ok', detail: linux ? 'Linux, supported' : 'macOS, supported' }
25
+ : { name: 'platform', status: 'fail', detail: `${platform} is not supported yet`, fix: 'Run on macOS or Linux' });
25
26
  checks.push(atLeast(nodeVersion, '22.18')
26
27
  ? { name: 'Node', status: 'ok', detail: nodeVersion }
27
28
  : { name: 'Node', status: 'fail', detail: `${nodeVersion} is too old`, fix: 'Install Node 22.18 or newer' });
28
29
  const git = exec('git', ['--version']);
29
30
  checks.push(git.ok
30
31
  ? { name: 'git', status: 'ok', detail: git.stdout }
31
- : { name: 'git', status: 'fail', detail: 'not found', fix: 'xcode-select --install' });
32
+ : { name: 'git', status: 'fail', detail: 'not found', fix: linux ? 'Install git with your package manager' : 'xcode-select --install' });
32
33
  const gh = exec('gh', ['auth', 'token']);
33
34
  const ghInstalled = gh.ok || exec('gh', ['--version']).ok;
34
35
  checks.push(gh.ok
35
36
  ? { name: 'GitHub CLI', status: 'ok', detail: 'logged in' }
36
37
  : ghInstalled
37
38
  ? { name: 'GitHub CLI', status: 'warn', detail: 'not logged in', fix: 'gh auth login' }
38
- : { name: 'GitHub CLI', status: 'warn', detail: 'not found', fix: 'brew install gh' });
39
+ : { name: 'GitHub CLI', status: 'warn', detail: 'not found', fix: linux ? 'https://cli.github.com' : 'brew install gh' });
39
40
  const claude = exec('claude', ['--version']);
40
41
  const codex = exec('codex', ['--version']);
41
42
  checks.push(claude.ok
@@ -2,7 +2,7 @@ import * as p from '@clack/prompts';
2
2
  import { baseChecks, run } from "../checks.js";
3
3
  import { context, readConfig, writeConfig } from "../config.js";
4
4
  import { fetchTool, loadTool } from "../loader.js";
5
- import { findTool, registry } from "../registry.js";
5
+ import { findTool, registry, unavailable } from "../registry.js";
6
6
  import { needsTerminal, parseFlags } from "./args.js";
7
7
  import { usages } from "./help.js";
8
8
  import { Cancelled } from "../ui.js";
@@ -82,18 +82,18 @@ export async function setup(defaults = false) {
82
82
  const ctx = context(true, defaults);
83
83
  const config = readConfig();
84
84
  try {
85
- const available = registry.filter((t) => t.status !== 'planned').map((t) => t.name);
85
+ const available = registry.filter((t) => !unavailable(t)).map((t) => t.name);
86
86
  const picked = defaults
87
- ? [...new Set([...config.tools, ...available])]
87
+ ? [...new Set([...config.tools.filter((n) => available.includes(n)), ...available])]
88
88
  : await p.multiselect({
89
89
  message: 'Which tools do you want?',
90
90
  options: registry.map((t) => ({
91
91
  value: t.name,
92
92
  label: t.name,
93
- hint: t.status === 'planned' ? `${t.summary} (coming soon)` : t.summary,
94
- disabled: t.status === 'planned',
93
+ hint: unavailable(t) ? `${t.summary} (${unavailable(t)})` : t.summary,
94
+ disabled: !!unavailable(t),
95
95
  })),
96
- initialValues: config.tools,
96
+ initialValues: config.tools.filter((n) => available.includes(n)),
97
97
  required: false,
98
98
  });
99
99
  if (p.isCancel(picked))
@@ -1,12 +1,13 @@
1
1
  import { context, readConfig, writeConfig } from "../config.js";
2
2
  import { fetchTool, loadTool } from "../loader.js";
3
- import { findTool, registry } from "../registry.js";
3
+ import { findTool, registry, unavailable } from "../registry.js";
4
4
  import { needsTerminal, wantsHelp } from "./args.js";
5
5
  export function listTools() {
6
6
  const installed = new Set(readConfig().tools);
7
7
  const width = Math.max(...registry.map((t) => t.name.length));
8
8
  for (const t of registry) {
9
- const tag = installed.has(t.name) ? ' (added)' : t.status === 'planned' ? ' (coming soon)' : '';
9
+ const why = unavailable(t);
10
+ const tag = installed.has(t.name) ? ' (added)' : why ? ` (${why})` : '';
10
11
  console.log(`${t.name.padEnd(width)} ${t.summary}${tag}`);
11
12
  }
12
13
  return 0;
@@ -31,6 +32,10 @@ export async function addTool(name) {
31
32
  console.error(`${entry.name} is not released yet.`);
32
33
  return 1;
33
34
  }
35
+ if (unavailable(entry)) {
36
+ console.error(`${entry.name} runs on macOS only.`);
37
+ return 1;
38
+ }
34
39
  const ctx = context();
35
40
  if (!fetchTool(entry, ctx.configDir)) {
36
41
  console.error(`could not download ${entry.pkg}`);
@@ -3,6 +3,9 @@ export interface ToolEntry {
3
3
  summary: string;
4
4
  pkg: string;
5
5
  status: 'planned' | 'available';
6
+ platforms?: NodeJS.Platform[];
6
7
  }
7
8
  export declare const registry: ToolEntry[];
9
+ export declare const runsHere: (t: ToolEntry, platform?: NodeJS.Platform) => boolean;
10
+ export declare function unavailable(t: ToolEntry, platform?: NodeJS.Platform): string | null;
8
11
  export declare function findTool(name: string): ToolEntry | undefined;
package/dist/registry.js CHANGED
@@ -7,6 +7,7 @@ export const registry = [
7
7
  summary: 'Keep the Mac running on battery with the lid closed, with guardrails',
8
8
  pkg: '@voidlabs.gg/voidshift-awake',
9
9
  status: 'available',
10
+ platforms: ['darwin'],
10
11
  },
11
12
  {
12
13
  name: 'agents',
@@ -21,6 +22,13 @@ export const registry = [
21
22
  status: 'available',
22
23
  },
23
24
  ];
25
+ export const runsHere = (t, platform = process.platform) => !t.platforms || t.platforms.includes(platform);
26
+ // Why a tool cannot be added here, or null when it can.
27
+ export function unavailable(t, platform = process.platform) {
28
+ if (t.status === 'planned')
29
+ return 'coming soon';
30
+ return runsHere(t, platform) ? null : 'macOS only';
31
+ }
24
32
  export function findTool(name) {
25
33
  return registry.find((t) => t.name === name);
26
34
  }
@@ -63,8 +63,10 @@ export interface WorkflowItem {
63
63
  name: string;
64
64
  triggers: {
65
65
  source: string;
66
- team: string;
67
- label: string;
66
+ team?: string;
67
+ label?: string;
68
+ schedule?: string;
69
+ task?: string;
68
70
  }[];
69
71
  steps: string[];
70
72
  maxRounds: number;
@@ -8,7 +8,7 @@ import { useBackGuard, useEquivalent, useHints, useKeys, useScreen } from "../co
8
8
  import { theme } from "../theme.js";
9
9
  import { Panel, Select, TextInput } from "../widgets.js";
10
10
  import { tilde } from "../format.js";
11
- import { matchWarnings, stepLabel, stepName, triggerText, warningText, sameTriggerLike } from "./workflows.js";
11
+ import { matchWarnings, stepLabel, stepName, triggerSpec, triggerText, warningText, sameTriggerLike } from "./workflows.js";
12
12
  const h = React.createElement;
13
13
  export function formReducer(s, a) {
14
14
  switch (a.type) {
@@ -38,7 +38,7 @@ export function formReducer(s, a) {
38
38
  }
39
39
  export function initialForm(w, name, projects) {
40
40
  if (w)
41
- return { name, triggers: w.triggers.map((t) => ({ team: t.team, label: t.label })), steps: [...w.steps], project: w.project, rounds: w.maxRounds };
41
+ return { name, triggers: w.triggers.map((t) => ({ ...t })), steps: [...w.steps], project: w.project, rounds: w.maxRounds };
42
42
  return { name: '', triggers: [], steps: [], project: projects.length === 1 ? projects[0] : '', rounds: 3 };
43
43
  }
44
44
  const FALLBACK_PRODUCERS = ['vs-plan', 'vs-dev'];
@@ -95,7 +95,7 @@ export function saveArgs(s, isNew, catalog) {
95
95
  'workflow',
96
96
  isNew ? 'add' : 'edit',
97
97
  s.name.trim(),
98
- ...s.triggers.flatMap((t) => ['--trigger', `${t.team}:${t.label}`]),
98
+ ...s.triggers.flatMap((t) => ['--trigger', triggerSpec(t)]),
99
99
  '--steps',
100
100
  s.steps.map((id) => stepName(catalog, id)).join(','),
101
101
  '--project',
@@ -105,17 +105,19 @@ export function saveArgs(s, isNew, catalog) {
105
105
  ];
106
106
  }
107
107
  export const renameArgs = (from, to) => ['agents', 'workflow', 'rename', from, to];
108
- // A trigger from the two text fields, or a complaint.
108
+ // A trigger from the two text fields, or a complaint. A team written as
109
+ // voidtrack:ORG is a VoidTrack team; schedules are added with the CLI.
109
110
  export function makeTrigger(team, label) {
110
- const t = team.trim().toUpperCase();
111
+ const voidtrack = /^voidtrack:/i.test(team.trim());
112
+ const t = team.trim().replace(/^voidtrack:/i, '').toUpperCase();
111
113
  const l = label.trim();
112
114
  if (!t)
113
- return { error: 'Give the Linear team, like ENG.' };
115
+ return { error: 'Give the team, like ENG, or voidtrack:ORG for VoidTrack.' };
114
116
  if (t.includes(':') || /\s/.test(t))
115
- return { error: 'The team is a short key like ENG.' };
117
+ return { error: 'The team is a short key like ENG, or voidtrack:ORG.' };
116
118
  if (!l)
117
119
  return { error: 'Give the label, like vs-plan.' };
118
- return { trigger: { team: t, label: l } };
120
+ return { trigger: { source: voidtrack ? 'voidtrack' : 'linear', team: t, label: l } };
119
121
  }
120
122
  // Warnings for triggers this workflow shares with another. In place when
121
123
  // editing, last in order when new.
@@ -348,7 +350,7 @@ function WorkflowForm(props) {
348
350
  // Triggers
349
351
  h(Box, null, h(Label, { text: 'Starts when', focused: where === 'triggers' }), h(Box, { flexDirection: 'column', flexGrow: 1 }, form.triggers.length === 0 ? h(Text, { color: theme.dim }, ' no trigger yet') : null, ...form.triggers.map((t, i) => {
350
352
  const on = where === 'triggers' && i === tCursor && adding === null;
351
- return h(Text, { key: `${t.team}:${t.label}`, backgroundColor: on ? theme.selected : undefined, bold: on, color: on ? theme.bold : undefined }, ` ${triggerText(t)} `);
353
+ return h(Text, { key: triggerSpec(t), backgroundColor: on ? theme.selected : undefined, bold: on, color: on ? theme.bold : undefined }, ` ${triggerText(t)} `);
352
354
  }), adding !== null
353
355
  ? h(Box, { flexDirection: 'column' }, h(Box, null, h(Text, { color: theme.dim }, ' team '), h(TextInput, { value: newTeam, onChange: setNewTeam, focus: adding === 'team', placeholder: 'ENG', onSubmit: () => setAdding('label') }), h(Text, { color: theme.dim }, ' label '), h(TextInput, { value: newLabel, onChange: setNewLabel, focus: adding === 'label', placeholder: 'vs-plan', onSubmit: commitTrigger })), addError ? h(Text, { color: theme.red }, ` ${addError}`) : null)
354
356
  : null), h(Box, { flexShrink: 0, marginLeft: 2 }, h(Text, { color: theme.dim }, where === 'triggers' ? 'a add trigger · x remove' : ''))), h(Text, null, ' '),
@@ -4,10 +4,14 @@ import type { Screen } from '../screens.ts';
4
4
  export declare function stepLabel(catalog: StepsCatalog | null, id: string): string;
5
5
  export declare function stepName(catalog: StepsCatalog | null, id: string): string;
6
6
  export interface TriggerLike {
7
- team: string;
8
- label: string;
7
+ source?: string;
8
+ team?: string;
9
+ label?: string;
10
+ schedule?: string;
11
+ task?: string;
9
12
  }
10
13
  export declare const triggerText: (t: TriggerLike) => string;
14
+ export declare const triggerSpec: (t: TriggerLike) => string;
11
15
  export declare const triggersText: (triggers: TriggerLike[]) => string;
12
16
  export declare const sameTriggerLike: (a: TriggerLike, b: TriggerLike) => boolean;
13
17
  export declare const stepsText: (catalog: StepsCatalog | null, steps: string[]) => string;
@@ -28,9 +28,11 @@ export function stepLabel(catalog, id) {
28
28
  export function stepName(catalog, id) {
29
29
  return catalog?.steps.find((s) => s.id === id)?.name ?? id.replace(/^vs-/, '');
30
30
  }
31
- export const triggerText = (t) => `${t.team} + ${t.label}`;
31
+ export const triggerText = (t) => t.source === 'cron' ? `cron ${t.schedule}` : `${t.source === 'voidtrack' ? 'VoidTrack ' : ''}${t.team} + ${t.label}`;
32
+ // The form `--trigger` takes.
33
+ export const triggerSpec = (t) => t.source === 'cron' ? `cron:${t.schedule}:${t.task}` : t.source === 'voidtrack' ? `voidtrack:${t.team}:${t.label}` : `${t.team}:${t.label}`;
32
34
  export const triggersText = (triggers) => triggers.map(triggerText).join(', ');
33
- export const sameTriggerLike = (a, b) => a.team === b.team && a.label === b.label;
35
+ export const sameTriggerLike = (a, b) => triggerSpec(a) === triggerSpec(b);
34
36
  export const stepsText = (catalog, steps) => steps.map((s) => stepLabel(catalog, s)).join(' → ');
35
37
  // Two workflows that start on the same TEAM:LABEL: only the first in order
36
38
  // starts the card. One entry per (loser, winner) pair.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voidlabs.gg/voidshift",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "One CLI for the Void dev tools: keep your Mac awake, run agents in the background, shared skills and memory for Claude Code and Codex.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -18,7 +18,8 @@
18
18
  "agents",
19
19
  "linear",
20
20
  "cli",
21
- "macos"
21
+ "macos",
22
+ "linux"
22
23
  ],
23
24
  "type": "module",
24
25
  "bin": {
@@ -30,9 +31,6 @@
30
31
  "LICENSE",
31
32
  "README.md"
32
33
  ],
33
- "os": [
34
- "darwin"
35
- ],
36
34
  "engines": {
37
35
  "node": ">=22.18"
38
36
  },