Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- `agentworkforce --version` tells you when a newer release is published and
prints the command that installs it.

## [4.1.39] - 2026-08-11

### Added
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,25 @@ agentworkforce harness check
agentworkforce --version
```

`agentworkforce --version` prints the installed version on stdout and, when a
newer release is published, writes the upgrade command to stderr:

```text
$ agentworkforce --version
4.1.45
Update available: 4.1.45 → 4.2.0
Run `npm install -g agentworkforce@latest` to update.
```

The check reads the `latest` dist-tag from the npm registry (or
`AGENTWORKFORCE_REGISTRY` / npm's configured registry) and stays silent when it
cannot reach one, so the version itself always prints. It is bounded by
`AGENTWORKFORCE_UPDATE_CHECK_TIMEOUT_MS` (1s by default), which is also the
most it can add to a run against an unresponsive registry. Set
`AGENTWORKFORCE_NO_UPDATE_CHECK=1` (or `NO_UPDATE_NOTIFIER=1`) to skip it
entirely. A CLI installed as a project dependency is told to update itself
without `-g`.

Local personas resolve from project-local files, configured source directories,
the personal persona directory, and the small built-in catalog. Higher layers
override lower layers field by field, so a repo can extend a reusable pack
Expand Down
8 changes: 8 additions & 0 deletions packages/agentworkforce/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- `agentworkforce --version` surfaces the update notice from the
`@agentworkforce/cli` build it validated, and suggests `npm install
agentworkforce@latest` without `-g` when a project-local install is what ran —
including when the project's own launcher is the one invoked, via `npx`,
`node_modules/.bin`, or an npm script.

## [4.1.41] - 2026-08-14

### Released
Expand Down
52 changes: 43 additions & 9 deletions packages/agentworkforce/bin/agentworkforce.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,13 @@ function resolveCli() {
].join('\n')
);
}
return { version: project.cli.version, entryUrl: project.cli.entryUrl };
return {
version: project.cli.version,
entryUrl: project.cli.entryUrl,
// A newer project dependency beat the invoked install, so this is
// definitively project-local: `-g` would update a different copy.
scope: 'project'
};
}

// Only require the invoked wrapper's dependency after checking whether a
Expand Down Expand Up @@ -79,7 +85,12 @@ function resolveCli() {

return {
version: bundled.version,
entryUrl: bundled.entryUrl
entryUrl: bundled.entryUrl,
// Not necessarily global: this branch is also taken when the invoked
// wrapper *is* the project's own (npx, node_modules/.bin, an npm script),
// because resolveProjectInstall() skips a candidate that is this very
// file. Leave the scope to be inferred from where the entry resolved.
scope: undefined
};
}

Expand Down Expand Up @@ -218,6 +229,27 @@ function parseVersion(version, label = 'package') {
};
}

/**
* Tell the user when the resolved implementation is behind the published
* `latest`, and how to update. The check lives in the CLI package so both this
* wrapper and a directly-invoked `dist/cli.js` print the same notice; an
* installation old enough to predate that module, or any failure inside it,
* simply prints no notice.
*/
async function reportAvailableUpdate(cli) {
try {
const { writeUpdateNotice } = await import(
new URL('./update-check.js', cli.entryUrl).href
);
await writeUpdateNotice(cli.version, {
scope: cli.scope,
moduleUrl: cli.entryUrl
});
} catch {
// Never let an update check fail `--version`.
}
}

try {
// Resolve and validate the implementation even for --version. Reporting the
// wrapper version alone used to hide partially-updated installations where
Expand All @@ -226,14 +258,16 @@ try {

if (process.argv[2] === '-v' || process.argv[2] === '--version') {
process.stdout.write(`${cli.version}\n`);
process.exit(0);
await reportAvailableUpdate(cli);
// Exit by running out of work rather than through process.exit(), which
// can terminate before a pending write to a piped stdout/stderr flushes.
} else {
// Import the entry from the exact package whose version was checked above;
// do not ask the module resolver a second time and risk selecting a
// different hoisted or nested copy.
const { main } = await import(cli.entryUrl);
await main();
}

// Import the entry from the exact package whose version was checked above;
// do not ask the module resolver a second time and risk selecting a different
// hoisted or nested copy.
const { main } = await import(cli.entryUrl);
await main();
} catch (err) {
process.stderr.write(
`${err instanceof InstallationError ? err.message : (err?.stack ?? String(err))}\n`
Expand Down
133 changes: 130 additions & 3 deletions packages/agentworkforce/test/version.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,37 @@ import { spawn } from 'node:child_process';
import { chmod, cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { fileURLToPath, pathToFileURL } from 'node:url';

const pkg = JSON.parse(
await readFile(new URL('../package.json', import.meta.url), 'utf8')
);
const binPath = fileURLToPath(new URL('../bin/agentworkforce.js', import.meta.url));

/**
* Stand-in for the CLI's compiled update-check module. It echoes the arguments
* the wrapper handed it, so what the wrapper claims about the install — and
* what it leaves for the module to infer — is observable from the outside.
*/
const UPDATE_CHECK_STUB = [
'export async function writeUpdateNotice(version, options = {}) {',
' const seen = {',
' version,',
' scope: options.scope ?? null,',
' moduleUrl: options.moduleUrl ?? null',
' };',
' process.stderr.write(`UPDATE NOTICE ${JSON.stringify(seen)}\\n`);',
'}',
''
].join('\n');

/** The single `UPDATE NOTICE` line the stub emitted, parsed back into an object. */
function parseUpdateNotice(stderr) {
const match = /^UPDATE NOTICE (.*)$/m.exec(stderr);
assert.ok(match, `expected an UPDATE NOTICE line, got: ${JSON.stringify(stderr)}`);
return JSON.parse(match[1]);
}

async function runBin(targetBinPath, args, options = {}) {
const child = spawn(process.execPath, [targetBinPath, ...args], {
cwd: options.cwd,
Expand Down Expand Up @@ -48,6 +72,92 @@ test('agentworkforce --version prints the implementation version it validated',
assert.equal(stdout, `${pkg.version}\n`);
});

test('--version delegates the update check to the CLI it validated', async (t) => {
const fixture = await createInstalledTree(t, {
wrapperVersion: '4.1.26',
cliVersion: '4.1.26',
updateNotice: true
});

const { exitCode, stdout, stderr } = await runBin(
fixture.binPath,
['--version'],
{ cwd: fixture.root }
);

assert.equal(exitCode, 0);
// The version stays alone on stdout; the notice is stderr-only.
assert.equal(stdout, '4.1.26\n');
const notice = parseUpdateNotice(stderr);
assert.equal(notice.version, '4.1.26');
// The wrapper hands over the entry it actually validated, so the module can
// work out where the running copy lives.
assert.ok(
notice.moduleUrl?.endsWith('/@agentworkforce/cli/dist/cli.js'),
`expected the validated entry URL, got: ${notice.moduleUrl}`
);
});

test('--version leaves the scope open when the project runs its own launcher', async (t) => {
// `npx agentworkforce`, `node_modules/.bin/agentworkforce`, and npm scripts
// all execute the project's own launcher. resolveProjectInstall() then finds
// a candidate that is this very file and returns undefined, so the bundled
// branch runs for an install that is entirely project-local. Claiming
// 'global' there would send the user to `npm install -g`, updating a copy
// that never ran; the wrapper must leave the scope to be inferred from where
// the entry resolved.
const fixture = await createInstalledTree(t, {
wrapperVersion: '4.1.26',
cliVersion: '4.1.26',
projectWrapperVersion: '4.1.26',
projectCliVersion: '4.1.26',
projectCliLayout: 'hoisted',
installProjectLauncher: true,
updateNotice: true
});

const { exitCode, stdout, stderr } = await runBin(
fixture.projectBinPath,
['--version'],
{ cwd: fixture.projectRoot }
);

assert.equal(exitCode, 0);
assert.equal(stdout, '4.1.26\n');
const notice = parseUpdateNotice(stderr);
assert.equal(notice.scope, null);
// The entry it handed over is the project's own copy, which is what makes
// resolveInstallScope() answer 'project'.
const projectModules = `${pathToFileURL(fixture.projectRoot).href}/node_modules/`;
assert.ok(
notice.moduleUrl?.startsWith(projectModules),
`expected an entry inside the project tree, got: ${notice.moduleUrl}`
);
});

test('--version reports a project-local install as project-scoped', async (t) => {
const fixture = await createInstalledTree(t, {
wrapperVersion: '4.1.25',
cliVersion: '4.1.25',
projectWrapperVersion: '4.1.26',
projectCliVersion: '4.1.26',
projectCliLayout: 'hoisted',
updateNotice: true
});

const { exitCode, stdout, stderr } = await runBin(
fixture.binPath,
['--version'],
{ cwd: fixture.projectRoot }
);

assert.equal(exitCode, 0);
assert.equal(stdout, '4.1.26\n');
// A newer project dependency beat the invoked install, which the wrapper —
// unlike the module — knows for certain, so it says so.
assert.equal(parseUpdateNotice(stderr).scope, 'project');
});

test('refuses to execute a stale nested CLI and reports both resolved versions', async (t) => {
const fixture = await createInstalledTree(t, {
wrapperVersion: '4.1.26',
Expand Down Expand Up @@ -323,7 +433,9 @@ async function createInstalledTree(t, {
bareProjectCliVersion,
omitCliPackage,
omitCliEntry,
projectWrapperExports
projectWrapperExports,
updateNotice,
installProjectLauncher
}) {
const tempParent = await mkdtemp(path.join(os.tmpdir(), 'agentworkforce install '));
const root = path.join(tempParent, 'global tree');
Expand Down Expand Up @@ -356,9 +468,13 @@ async function createInstalledTree(t, {
)}); }\n`
);
}
if (updateNotice) {
await writeFile(path.join(cliRoot, 'dist', 'update-check.js'), UPDATE_CHECK_STUB);
}
}

const projectRoot = path.join(tempParent, 'project tree');
let projectBinPath;
if (projectWrapperVersion) {
const projectWrapperRoot = path.join(projectRoot, 'node_modules', 'agentworkforce');
const projectCliRoot = projectCliLayout === 'hoisted'
Expand Down Expand Up @@ -394,6 +510,17 @@ async function createInstalledTree(t, {
`PROJECT CLI ${projectCliVersion} EXECUTED\n`
)}); }\n`
);
if (updateNotice) {
await writeFile(path.join(projectCliRoot, 'dist', 'update-check.js'), UPDATE_CHECK_STUB);
}
if (installProjectLauncher) {
// The real launcher, installed as the project's own `agentworkforce`
// dependency — what `npx` and `node_modules/.bin` actually execute.
projectBinPath = path.join(projectWrapperRoot, 'bin', 'agentworkforce.js');
await mkdir(path.dirname(projectBinPath), { recursive: true });
await cp(binPath, projectBinPath);
await chmod(projectBinPath, 0o755);
}
} else if (bareProjectCliVersion) {
const projectCliRoot = path.join(projectRoot, 'node_modules', '@agentworkforce', 'cli');
await mkdir(path.join(projectCliRoot, 'dist'), { recursive: true });
Expand All @@ -413,7 +540,7 @@ async function createInstalledTree(t, {
await mkdir(projectRoot, { recursive: true });
}

return { root, projectRoot, binPath: fixtureBinPath };
return { root, projectRoot, binPath: fixtureBinPath, projectBinPath };
}

function escapeRegExp(value) {
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- `--version` now checks the npm registry for a newer published release and,
when the installed build is behind, writes `Update available: <current> →
<latest>` and the install command to stderr. stdout stays exactly the version
string. The check is best-effort (1s timeout, silent on any failure), honours
`AGENTWORKFORCE_REGISTRY` / `npm_config_registry`, and is skipped with
`AGENTWORKFORCE_NO_UPDATE_CHECK=1` or `NO_UPDATE_NOTIFIER=1`. A CLI resolved
from the working tree's own `node_modules` — including an ancestor's — is
told to update without `-g`.

## [4.1.46] - 2026-08-19

### Added
Expand Down
13 changes: 11 additions & 2 deletions packages/cli/src/cli-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,10 @@ Commands:

Options:
-h, --help Show this help text.
-v, --version Print the agentworkforce version.
-v, --version Print the agentworkforce version, and note on stderr
when a newer one is published along with the command
that installs it. Skip the check with
AGENTWORKFORCE_NO_UPDATE_CHECK=1.

Local personas cascade: <cwd>/.agentworkforce/workforce/personas/*.json → configured persona dirs → repo library.
Each layer only needs to specify fields it overrides; everything else inherits
Expand Down Expand Up @@ -5069,7 +5072,13 @@ export async function main(): Promise<void> {

if (subcommand === '-v' || subcommand === '--version') {
process.stdout.write(`${CLI_VERSION}\n`);
process.exit(0);
// Best-effort, stderr-only, and never fatal: the version itself is already
// on stdout before the registry is asked anything.
const { writeUpdateNotice } = await import('./update-check.js');
await writeUpdateNotice(CLI_VERSION);
Comment thread
willwashburn marked this conversation as resolved.
Comment thread
willwashburn marked this conversation as resolved.
// Return rather than process.exit(0), which can terminate before a pending
// write to a piped stdout/stderr flushes.
return;
}

if (subcommand === 'list') {
Expand Down
Loading
Loading