Audited comments project-wide against the current implementation: - scripts/install.sh, scripts/uninstall.sh: header curl URLs pointed at a root-level install.sh/uninstall.sh, but the files live under scripts/ — the documented command 404'd. Corrected to the scripts/ path (matching the README and the actual file location). - src/move.ts: header said it ties "four logic modules" and omitted editor.ts; the --edit terminal action was also missing from the order of operations. Both corrected. - src/config.ts: numeric Config fields are all milliseconds now; dropped the stale "pixels" unit left over from stepCount/stepSize. Comments-only (plus two script header lines); tsc clean, 64 tests pass.
Teams Status Keeper
Keeps Microsoft Teams (or any presence-tracking app) from marking you as "Away" by nudging the mouse cursor when the machine has been idle long enough to trigger an idle timeout.
Real user movement always wins: the script never fires while the user is actively using the mouse, and any synthetic sweep aborts the moment the cursor leaves the position the script just commanded.
Requirements
- Bun >= 1.0.0
- macOS or Linux (relies on
@nut-tree-fork/nut-jsfor cross-platform mouse + screen control) - On macOS: Accessibility permission for the terminal running Bun (System Settings > Privacy & Security > Accessibility)
Install
curl -fsSL https://gitea.cahlen.com/nokeo08/Move/raw/branch/master/scripts/install.sh | sh
This fetches the latest master from Gitea, runs bun install --production
under the install dir, and drops a move wrapper on your bin dir.
The installer respects the XDG Base Directory Specification:
- Source lives at
$XDG_DATA_HOME/move(default~/.local/share/move). - Wrapper goes to
$XDG_BIN_HOME/move(default~/.local/bin/move).XDG_BIN_HOMEis the widely-recognized de facto convention; XDG itself doesn't standardize a user bin dir.
Env vars (all optional):
| Var | Default | Purpose |
|---|---|---|
MOVE_VERSION |
master |
Branch or tag to install. Pin with e.g. v1.0.0. |
MOVE_FORCE |
unset | Set to 1 to reinstall when the same version is already present. |
XDG_DATA_HOME |
$HOME/.local/share |
Where the source tree is installed (under move/). |
XDG_BIN_HOME |
$HOME/.local/bin |
Where the move wrapper is placed. |
Bun must already be installed; the installer fails with a clear pointer to https://bun.sh if it isn't.
If your bin dir isn't on PATH, the installer prints the line to add to
your shell rc. It will not modify rc files for you.
Run
move
move --help
Stop with Ctrl+C. If SIGINT arrives mid-sweep, the cursor stays at
whichever step was last commanded — by design.
Uninstall
curl -fsSL https://gitea.cahlen.com/nokeo08/Move/raw/branch/master/scripts/uninstall.sh | sh
Removes the wrapper at $XDG_BIN_HOME/move and the install tree at
$XDG_DATA_HOME/move. Bun stays — it's your runtime, not ours.
Your config file at $XDG_CONFIG_HOME/move/config.json is intentionally
left behind, whether you customized it or never touched the seeded
defaults. The uninstaller prints a one-line notice pointing at the path
so you can remove it manually if you want:
rm -rf "${XDG_CONFIG_HOME:-$HOME/.config}/move"
Usage
Usage: move [options]
Options:
-h, --help Show this help and exit.
-v, --version Print version and exit.
-e, --edit Open the config file in $EDITOR and exit.
-C, --config <path> Load defaults from a JSON config file.
Default path: see the Configuration section.
-m, --move-interval <seconds> Idle time before a sweep fires. Default: 240.
-c, --check-interval <seconds> Cursor poll cadence. Default: 10.
-d, --step-delay <ms> Pause between synthetic steps. Default: 50.
-p, --pattern <name> Movement strategy. Default: line.
One of: line, diagonal, jitter, walk, arc,
figureEight. Each pattern defines its own
size and speed.
-V, --verbose Log every sweep, interrupt, and bounds event
(default prints only the startup banner).
Precedence (highest wins): CLI flags > config file > built-in defaults.
Run move --help for the resolved default config-file path on your
system. Numeric overrides are layered onto the defaults via
resolveConfig in src/config.ts; time-valued inputs (-m, -c) are
expressed in seconds at the CLI boundary and converted to milliseconds
internally.
Logging is quiet by default: only the startup banner ("Teams Status
Keeper started…") and any error from an unhandled rejection print on a
default run. -V / --verbose opens up per-sweep, user-interrupt, and
out-of-bounds events.
Invalid input (unknown flag, missing value, non-positive number) prints an
error to stderr and exits with code 2.
Configuration
move reads an optional JSON config file at:
${XDG_CONFIG_HOME:-$HOME/.config}/move/config.json
The installer seeds this file with the default values on a fresh install,
only if no file already exists at that path. Existing configs — yours
or from a previous install — are never overwritten. If you remove the
file later, move still works: missing defaults fall back to the values
baked into the binary (which match what was seeded, since both come from
scripts/config.default.json).
Pass -C / --config <path> to point at a different file; in that mode
the file must exist.
Precedence
CLI flags > config file > built-in defaults
CLI flags always win. The config file fills in any flag the user didn't pass on the command line. Built-in defaults fill in anything the file doesn't set.
Example
{
"moveInterval": 240,
"checkInterval": 10,
"stepDelay": 50,
"pattern": "line",
"verbose": false
}
All keys are optional; supply only the ones you want to override. Keys
and units mirror the CLI flags exactly: moveInterval and
checkInterval are seconds, stepDelay is milliseconds, pattern is a
movement strategy name, verbose is a boolean.
The obsolete
stepCount/stepSizekeys (removed in 1.3.0) are tolerated for backward compatibility: they're ignored with a one-line notice rather than rejected, so a config seeded by an older install keeps working. Sweep size and step count are now properties of each pattern.
Editing
move -e # or --edit
move --edit --config /path/to/another.json
Opens the active config file in $EDITOR (honors flags in the value,
so EDITOR="code --wait" and EDITOR=vim both work). Refuses with
exit 2 if:
$EDITORis unset or empty.- The target file doesn't exist. (Run
moveonce or reinstall to re-seed the default file.)
The editor's own exit code is propagated, so you can chain
move -e && move to validate-by-running after every edit.
Validation
The loader is strict:
- Root must be a JSON object.
- Unknown keys are rejected (catches typos like
"movInterval"). - Numeric values must be finite and strictly positive.
patternmust resolve to a registered strategy name. Matching ignores case and separators (-,_, spaces), sofigure-eightandfigureEightare equivalent.verbosemust be a boolean.
Any validation failure prints a message naming the file and the offending
key to stderr and exits 2.
Known limitation: verbose can be turned on but not off from the CLI
--verbose is a presence-only flag (there is no --no-verbose). If the
config file sets "verbose": true, the CLI cannot force quiet mode in
that invocation. Workarounds: edit the file, or point at a different
file with --config.
How it works
For a step-by-step trace of a clean sweep, see the execution happy-path sequence diagram.
The source lives under src/, split into an entry point plus logic
modules:
src/move.tsis a thin entry point: parses args, dispatches--help/--version, loads the config file, resolves the layered runtime config, and callsrunKeeper(config).src/cli.tsowns argument parsing, validation, and help/version output.src/configFile.tsowns optional JSON config-file loading + strict schema validation.src/config.tsexports theConfigtype (which carries every tunable includingverbose),DEFAULT_CONFIG,defaultConfigPath, and the layeredresolveConfigoverlay function.src/keeper.tsowns the idle-watch loop and the per-sweep glue that wires a strategy to the executor.
Movement itself is split across three seams so patterns are easy to add and everything but the raw nut.js call is unit-testable:
src/device.tsis the I/O boundary: aDeviceinterface (getPosition/setPosition/width/height/sleep) plus the nut.js implementation. It's the only module that imports nut.js, and it's injectable, so tests drive the loop and executor with a fake.src/strategies.tsholds the pure movement patterns — each a generator of target points given a start, screen size, config, and RNG — plus the registry and name validation. Adding a pattern is one pure function.src/executor.tsis the singleexecutePathdriver: it rounds targets, applies the strategy's bounds policy, paces steps, detects real-user interruption, and restores the cursor on a clean sweep.
Defaults live in src/config.ts as DEFAULT_CONFIG:
| Field | Default | CLI flag | Purpose |
|---|---|---|---|
moveInterval |
4 * 60_000 |
-m, --move-interval |
Idle time (ms) required before a synthetic sweep fires. |
checkInterval |
10_000 |
-c, --check-interval |
How often (ms) the main loop polls the cursor for real activity. |
stepDelay |
50 |
-d, --step-delay |
Pause (ms) between individual synthetic steps in a sweep. |
pattern |
"line" |
-p, --pattern |
Movement strategy name (see Movement strategies below). |
-m and -c are accepted in seconds at the CLI; resolveConfig converts
to milliseconds before handing the resolved Config to runKeeper.
Main loop (runKeeper)
- Print the startup banner (unconditional).
- Snapshot
lastPosandlastActivity = now. - Every
config.checkInterval:- If the cursor moved since the last check, the user is active — reset
lastActivityandlastPos, continue. - Otherwise, if
now - lastActivity >= config.moveInterval, callsimulateActivityand reset the idleness clock.
- If the cursor moved since the last check, the user is active — reset
Synthetic sweep (simulateActivity + executePath)
simulateActivitysnapshots the starting position and current screen dimensions (re-read every sweep so monitor changes are handled), looks upconfig.patternin the strategy registry, and builds aMoveContext.- It hands the strategy and context to
executePath, which drives the sweep. For each target the strategy yields:- Round to whole pixels and apply the strategy's bounds policy
(
abort/clamp/reflect) to keep it on-screen. - Move the cursor there, sleep
config.stepDelay. - Re-read the cursor. If it isn't at the point we just commanded, the
user moved it — log (when
--verbose) and return early without snapping back.
- Round to whole pixels and apply the strategy's bounds policy
(
- On a clean full sweep, restore the cursor to its starting position so the next idle-check sees "no movement" and doesn't misread the synthetic activity as real user input.
Comparing against the last commanded (rounded) point — not the strategy's
ideal, possibly fractional target — is what lets curved and stochastic
patterns run without every rounded step looking like user activity. The
comparison also allows a small (2px) tolerance, and the clamp/reflect
patterns stay a couple of pixels off the screen edge, so sub-pixel cursor
placement on scaled or multi-monitor displays isn't misread as the user
grabbing the mouse. line uses the abort policy and is unaffected.
Movement strategies
config.pattern selects one of the generators in src/strategies.ts:
| Name | Motion | Steps | Size | Bounds |
|---|---|---|---|---|
line |
Straight horizontal sweep (the original behavior). | 250 | 250px | abort |
diagonal |
Straight line on both axes toward the roomiest corner. | 250 | 250px/axis | clamp |
jitter |
Small random hops within a tight radius of the start. | 80 | 30px radius | clamp |
walk |
Cumulative random walk; bounces off the screen edges. | 200 | ±4px/step | reflect |
arc |
Smooth quadratic-Bézier curve to a random on-screen point. | 120 | ~300px | clamp |
figureEight |
Traces a figure-eight (lemniscate) and returns to the start. | 90 | ~250px wide | clamp |
Each pattern owns its geometry — how many steps it takes and how far it
reaches — as constants in src/strategies.ts. Those are properties of the
pattern, not user preferences, so there is no knob for sweep size or step
count; stepDelay (the per-step pause) is the only pacing lever, and it
scales every pattern's total duration. To add a pattern, write one pure
generator and register it — the executor supplies bounds, pacing, interrupt,
and restore for free.
Why mouse.config.autoDelayMs = 0
nut.js inserts a 100ms delay after every action by default. With two mouse
calls per step that would silently more-than-double the duration of a
sweep. The code controls cadence itself via config.stepDelay, so the
implicit delay is disabled in createNutDevice — the single place nut.js
is wired up. Importing the movement modules stays side-effect-free.
For contributors
Clone the repo and bootstrap a dev environment:
git clone https://gitea.cahlen.com/nokeo08/Move.git
cd Move
./scripts/dev-setup.sh
scripts/dev-setup.sh verifies Bun is installed and runs bun install
(with devDependencies, unlike the end-user scripts/install.sh). It
operates at the repo root regardless of the CWD you invoke it from.
Run from the source tree:
bun run start # via the package.json script
bun run src/move.ts # direct
bun run src/move.ts --help
Or install a global move pointed at your checkout:
bun link
move --help
Files
| File | Purpose |
|---|---|
scripts/install.sh |
End-user installer; curl-pipeable from Gitea. |
scripts/uninstall.sh |
End-user uninstaller; curl-pipeable from Gitea. |
scripts/dev-setup.sh |
Contributor bootstrap (verify Bun + bun install). |
scripts/config.default.json |
Single source of truth for default values: imported by src/config.ts and copied to $XDG_CONFIG_HOME/move/config.json on a fresh install. |
src/move.ts |
CLI entry point: parses args, dispatches help/version, starts the loop. |
src/cli.ts |
Argument parsing, validation, and help/version output. |
src/config.ts |
Config type (carries every tunable, including verbose), DEFAULT_CONFIG (derived from scripts/config.default.json), defaultConfigPath, and the layered resolveConfig overlay. |
src/configFile.ts |
Optional JSON config-file loader with strict schema validation. |
src/editor.ts |
move --edit: opens the active config file in $EDITOR. |
src/errors.ts |
Shared error types (CliError). |
src/keeper.ts |
Idle-watch loop + per-sweep glue (selects a strategy, calls the executor). |
src/device.ts |
Device I/O seam over nut.js (Point, createNutDevice); the only nut.js importer. |
src/strategies.ts |
Pure movement-pattern generators, the strategy registry, and name validation. |
src/executor.ts |
executePath driver: bounds policy, pacing, interrupt detection, restore. |
docs/execution-happy-path.md |
Sequence diagram + invariants for a clean sweep. |
package.json |
Bun project manifest. Single runtime dep: @nut-tree-fork/nut-js. |
tsconfig.json |
Strict TypeScript config tuned for Bun (ESNext, bundler resolution). |
bun.lock |
Bun's lockfile. Commit this. |
LICENSE |
GPLv3 license text. |
License
GPL-3.0-only. See LICENSE.