Skip to content

Commit 8fc2009

Browse files
chrisleekrclaude
andauthored
feat(discovery): add agentsync ls and status --machine for cross-machine browse (#187)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4e4d1df commit 8fc2009

8 files changed

Lines changed: 421 additions & 11 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,8 @@ The full quickstart, command reference, and architecture model live at the docum
7777
| `init` | Create the local vault workspace, machine key, config, and initial remote state. |
7878
| `push` | Snapshot local agent configs, sanitise secrets, encrypt artefacts, and push to Git. |
7979
| `copy` | Restore an artefact (or subdir) from a machine's vault namespace to local disk (`copy self …` for your own). |
80-
| `status` | Compare local files with the vault and surface drift. |
80+
| `ls` | List machine namespaces, or the copyable artifact paths in one (key-free discovery). |
81+
| `status` | Compare local files with the vault and surface drift (any machine via `--machine`). |
8182
| `doctor` | Run environment, key, vault, and daemon diagnostics. |
8283
| `daemon` | Install and manage the background auto-sync daemon. |
8384
| `key` | Add, list, or remove recipients (revocation), or rotate the local machine key. |

docs/commands.md

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ When developing from source, replace the binary call with `bun run src/cli.ts`.
4343
| [`init`](#init) | Bootstrap the vault, machine key, and config. |
4444
| [`push`](#push) | Snapshot, sanitise, encrypt, and fast-forward this machine's namespace to the vault. |
4545
| [`copy`](#copy) | Apply an artefact (or subdir) from a machine's vault namespace to local disk (`copy self …` for your own). |
46-
| [`status`](#status) | Compare local snapshot to decrypted vault state. |
46+
| [`ls`](#ls) | List machine namespaces, or the copyable artifact paths in one. |
47+
| [`status`](#status) | Compare local snapshot to decrypted vault state (any machine via `--machine`). |
4748
| [`doctor`](#doctor) | Check the local environment before blaming sync logic. |
4849
| [`daemon`](#daemon) | Install, start, stop, and inspect the background daemon. |
4950
| [`key`](#key) | Add, list, or remove recipients, or rotate the current machine key. |
@@ -198,35 +199,61 @@ agentsync copy work-laptop claude/ --dry-run # preview the whole claude nam
198199
- Reconciliation is fast-forward only.
199200
- Plugins are not copyable via `copy` — they are reinstalled from the recorded manifest by `plugin install`.
200201

202+
## ls
203+
204+
**Why**: Discover what is in the vault before you `copy`. `copy` needs an exact logical path (e.g. `claude/CLAUDE.md.age`); `ls` is how you find those paths — especially on a fresh machine where you do not yet know another machine's layout.
205+
206+
**Usage**:
207+
208+
```bash
209+
agentsync ls # list every machine namespace in the vault
210+
agentsync ls work-laptop # list the copyable artifacts in that namespace
211+
agentsync ls work-laptop claude/ # narrow to a path prefix
212+
agentsync ls self # browse this machine's own backup
213+
```
214+
215+
**Arguments**:
216+
217+
| Argument | Description |
218+
|---|---|
219+
| `<machine>` | Machine namespace to browse, or `self`. Omit to list all machines. |
220+
| `<path>` | Optional path prefix to narrow the listing. |
221+
222+
**Outcome**: with no argument, the machine namespaces under `machines/`. With a machine, the logical `.age` paths you can hand to `copy <machine> <path>`. **Read-only and key-free** — it lists which encrypted files exist without decrypting them, so a machine that is not yet a recipient can still discover what is copyable. It reconciles fast-forward first so the listing reflects the latest backup.
223+
201224
## status
202225

203-
**Why**: Compare the local snapshot to the decrypted vault state for the enabled agents.
226+
**Why**: Compare the local snapshot to the decrypted vault state for the enabled agents — this machine's own backup by default, or another machine's via `--machine` to preview a `copy`.
204227

205228
**Usage**:
206229

207230
```bash
208231
agentsync status
209232
agentsync status --verbose
233+
agentsync status --machine work-laptop # diff local config against another machine's backup
210234
```
211235

212236
**Flags**:
213237

214238
| Flag | Default | Description |
215239
|---|---|---|
216240
| `--verbose` | `false` | Show per-file hashes alongside each row. |
241+
| `--machine` | this machine | Compare against another machine's namespace (or `self`). Needs the private key to decrypt; an unknown name lists the available machines. |
217242

218243
**Outcome**: a per-agent report covering every enabled agent. Each row carries one of the following status strings, printed verbatim:
219244

220245
- `synced` — local content matches the vault.
221246
- `local-changed` — both sides have the file but the content differs. Run `push` to publish the local copy, or `copy self <path>` to restore the vault copy after backing up the local one.
222247
- `local-only` — the machine has content the vault does not. Run `push`.
223-
- `vault-only` — this machine's namespace has content the local disk does not. Run `copy self <path>` to bring it down.
248+
- `vault-only` — the source namespace has content the local disk does not. Run `copy <machine> <path>` (or `copy self <path>` when comparing against `self`) to bring it down.
249+
- `unknown` — the private key was unavailable, so the vault row could not be decrypted and the comparison is inconclusive. Restore the key and re-run.
224250
- `error` — snapshot or decryption failed for that row. The error detail is printed in the same row; address it before trusting the rest of the report.
225251

226252
**Caveats**:
227253

228254
- `status` is read-only. It never mutates the vault or local files.
229255
- Vault-only entries marked "not on this machine" are normal when another machine snapshots an agent this machine does not enable.
256+
- `--machine` compares only the agents **this** machine has enabled. To browse another machine's full namespace (including agents you have disabled), use [`ls`](#ls).
230257

231258
## doctor
232259

src/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { destroyCommand } from "./commands/destroy";
77
import { doctorCommand } from "./commands/doctor";
88
import { initCommand } from "./commands/init";
99
import { keyCommand } from "./commands/key";
10+
import { lsCommand } from "./commands/ls";
1011
import { migrateCommand } from "./commands/migrate";
1112
import { pluginCommand } from "./commands/plugin";
1213
import { pushCommand } from "./commands/push";
@@ -27,6 +28,7 @@ const main = defineCommand({
2728
init: initCommand,
2829
push: pushCommand,
2930
copy: copyCommand,
31+
ls: lsCommand,
3032
status: statusCommand,
3133
doctor: doctorCommand,
3234
daemon: daemonCommand,

src/commands/__tests__/ls.test.ts

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
/**
2+
* Tests for `agentsync ls` — vault namespace + artifact discovery. Real git
3+
* fixtures, no mocking. Seeds two machine namespaces so the cross-machine
4+
* browse path (the point of the command) is exercised.
5+
*/
6+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
7+
import { mkdirSync, writeFileSync } from "node:fs";
8+
import { rm } from "node:fs/promises";
9+
import { createRequire } from "node:module";
10+
import { dirname, join } from "node:path";
11+
import { machineVaultRoot } from "../../config/paths";
12+
import {
13+
createBareRepo,
14+
createMachineFixture,
15+
createTmpDir,
16+
runGit,
17+
seedVaultRepo,
18+
type TestMachineFixture,
19+
} from "../../test-helpers/fixtures";
20+
21+
{
22+
const require = createRequire(import.meta.url);
23+
// biome-ignore lint/style/useNodejsImportProtocol: deliberate alias to bypass mock cache
24+
const realFsPromises = require("fs/promises") as typeof import("node:fs/promises");
25+
mock.module("node:fs/promises", () => ({ ...realFsPromises, default: realFsPromises }));
26+
}
27+
28+
mock.module("@clack/prompts", () => ({
29+
intro: () => {},
30+
outro: () => {},
31+
log: { success: () => {}, info: () => {}, warn: () => {}, error: () => {} },
32+
note: () => {},
33+
spinner: () => ({ start: () => {}, stop: () => {}, message: () => {} }),
34+
}));
35+
36+
type LsMod = typeof import("../ls");
37+
let lsMod: LsMod;
38+
39+
const RUNTIME_ENV_KEYS = ["AGENTSYNC_VAULT_DIR", "AGENTSYNC_KEY_PATH", "AGENTSYNC_MACHINE"];
40+
41+
/** Write a placeholder .age file under a machine namespace (content need not decrypt). */
42+
function seedArtifact(machine: TestMachineFixture, name: string, relPath: string): void {
43+
const target = join(machineVaultRoot(machine.vaultDir, name), relPath);
44+
mkdirSync(dirname(target), { recursive: true });
45+
writeFileSync(target, "ciphertext-placeholder", "utf8");
46+
}
47+
48+
describe("ls command", () => {
49+
let tmpDir: string;
50+
let machine: TestMachineFixture;
51+
let bare: string;
52+
const savedEnv: Record<string, string | undefined> = {};
53+
54+
beforeEach(async () => {
55+
lsMod = await import("../ls");
56+
tmpDir = await createTmpDir();
57+
machine = await createMachineFixture(tmpDir, "config-test");
58+
bare = await createBareRepo(tmpDir);
59+
seedVaultRepo({ machine, bareRepoPath: bare });
60+
61+
// Two machine namespaces: this machine (config-test) and a remote one.
62+
seedArtifact(machine, "config-test", join("codex", "AGENTS.md.age"));
63+
seedArtifact(machine, "work-laptop", join("claude", "CLAUDE.md.age"));
64+
seedArtifact(machine, "work-laptop", join("claude", "skills", "foo.tar.age"));
65+
runGit(["add", "."], machine.vaultDir);
66+
runGit(["commit", "-m", "seed namespaces"], machine.vaultDir);
67+
runGit(["push", "origin", "main"], machine.vaultDir);
68+
69+
for (const key of RUNTIME_ENV_KEYS) savedEnv[key] = process.env[key];
70+
process.env.AGENTSYNC_VAULT_DIR = machine.vaultDir;
71+
process.env.AGENTSYNC_KEY_PATH = machine.keyPath;
72+
process.env.AGENTSYNC_MACHINE = machine.machineName;
73+
process.exitCode = 0;
74+
});
75+
76+
afterEach(async () => {
77+
for (const key of RUNTIME_ENV_KEYS) {
78+
const value = savedEnv[key];
79+
if (value === undefined) delete process.env[key];
80+
else process.env[key] = value;
81+
}
82+
await rm(tmpDir, { recursive: true, force: true });
83+
});
84+
85+
test("no machine lists every namespace", async () => {
86+
const result = await lsMod.performLs({});
87+
expect(result.kind).toBe("machines");
88+
if (result.kind === "machines") {
89+
expect(result.machines).toEqual(["config-test", "work-laptop"]);
90+
}
91+
});
92+
93+
test("a machine lists its copyable artifact paths", async () => {
94+
const result = await lsMod.performLs({ machine: "work-laptop" });
95+
expect(result.kind).toBe("artifacts");
96+
if (result.kind === "artifacts") {
97+
expect(result.paths).toEqual(["claude/CLAUDE.md.age", "claude/skills/foo.tar.age"]);
98+
}
99+
});
100+
101+
test("a path prefix narrows the listing", async () => {
102+
const result = await lsMod.performLs({ machine: "work-laptop", path: "claude/skills" });
103+
expect(result.kind).toBe("artifacts");
104+
if (result.kind === "artifacts") {
105+
expect(result.paths).toEqual(["claude/skills/foo.tar.age"]);
106+
}
107+
});
108+
109+
test("self resolves to this machine's namespace", async () => {
110+
const result = await lsMod.performLs({ machine: "self" });
111+
expect(result.kind).toBe("artifacts");
112+
if (result.kind === "artifacts") {
113+
expect(result.paths).toEqual(["codex/AGENTS.md.age"]);
114+
}
115+
});
116+
117+
test("an unknown machine lists the available ones", async () => {
118+
const result = await lsMod.performLs({ machine: "ghost" });
119+
expect(result.kind).toBe("unknown-machine");
120+
if (result.kind === "unknown-machine") {
121+
expect(result.available).toContain("work-laptop");
122+
}
123+
});
124+
125+
test("an empty path reports empty", async () => {
126+
const result = await lsMod.performLs({ machine: "work-laptop", path: "cursor" });
127+
expect(result.kind).toBe("empty");
128+
});
129+
130+
test("a traversal path cannot escape the machine namespace", async () => {
131+
// `..` segments must not enumerate .age files outside the namespace.
132+
const result = await lsMod.performLs({ machine: "work-laptop", path: "../../../../etc" });
133+
expect(result.kind).toBe("empty");
134+
});
135+
136+
test("reports reconcile-error when vault history has diverged", async () => {
137+
// Advance the remote independently from a second clone.
138+
const otherClone = join(tmpDir, "other-clone");
139+
runGit(["clone", bare, otherClone]);
140+
runGit(["config", "user.email", "t@t.local"], otherClone);
141+
runGit(["config", "user.name", "t"], otherClone);
142+
writeFileSync(join(otherClone, "remote-extra.txt"), "remote\n", "utf8");
143+
runGit(["add", "."], otherClone);
144+
runGit(["commit", "-m", "remote advance"], otherClone);
145+
runGit(["push", "origin", "main"], otherClone);
146+
147+
// Advance the local vault on a divergent commit.
148+
writeFileSync(join(machine.vaultDir, "local-extra.txt"), "local\n", "utf8");
149+
runGit(["add", "."], machine.vaultDir);
150+
runGit(["commit", "-m", "local advance"], machine.vaultDir);
151+
152+
const result = await lsMod.performLs({ machine: "work-laptop" });
153+
expect(result.kind).toBe("reconcile-error");
154+
});
155+
156+
test("the CLI wrapper exits 1 on an unknown machine", async () => {
157+
process.exitCode = 0;
158+
await lsMod.lsCommand.run?.({
159+
args: { machine: "ghost" },
160+
rawArgs: [],
161+
cmd: {} as never,
162+
} as never);
163+
expect(process.exitCode).toBe(1);
164+
});
165+
});

src/commands/__tests__/status.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,4 +275,57 @@ describe("status surfaces skill drift", () => {
275275
);
276276
expect(skillRow).toBeDefined();
277277
});
278+
279+
test("--machine compares local config against another machine's namespace", async () => {
280+
const skillDir = join(mutableCopilotPaths.skillsDir, "peer-skill");
281+
mkdirSync(skillDir, { recursive: true });
282+
writeFileSync(join(skillDir, "SKILL.md"), "# peer fixture", "utf8");
283+
284+
const walker = await import("../../agents/skills-walker");
285+
const snap = await walker.collectSkillArtifacts("copilot", mutableCopilotPaths.skillsDir);
286+
const { encryptString } = await import("../../core/encryptor");
287+
const peerArtifact = snap.artifacts.find((a) =>
288+
a.vaultPath.endsWith("skills/peer-skill.tar.age"),
289+
);
290+
if (!peerArtifact) throw new Error("Expected peer-skill artifact in snapshot");
291+
const encrypted = await encryptString(peerArtifact.plaintext, [machine.recipient]);
292+
293+
// Write the artifact under a PEER machine's namespace, not this machine's.
294+
const vaultArtPath = join(
295+
machineVaultRoot(machine.vaultDir, "peer-machine"),
296+
"copilot",
297+
"skills",
298+
"peer-skill.tar.age",
299+
);
300+
await mkdir(dirname(vaultArtPath), { recursive: true });
301+
writeFileSync(vaultArtPath, encrypted, "utf8");
302+
303+
fakeLogs.info.length = 0;
304+
process.exitCode = 0;
305+
const statusMod = await import("../status");
306+
await statusMod.statusCommand.run?.({
307+
args: { verbose: false, machine: "peer-machine" },
308+
rawArgs: [],
309+
cmd: {} as never,
310+
} as never);
311+
312+
expect(fakeLogs.info.some((l) => l.includes("Source: peer-machine"))).toBe(true);
313+
const skillRow = fakeLogs.info.find(
314+
(line) => line.includes("peer-skill") && line.includes("synced"),
315+
);
316+
expect(skillRow).toBeDefined();
317+
});
318+
319+
test("--machine errors on an unknown machine namespace", async () => {
320+
fakeLogs.error.length = 0;
321+
process.exitCode = 0;
322+
const statusMod = await import("../status");
323+
await statusMod.statusCommand.run?.({
324+
args: { verbose: false, machine: "does-not-exist" },
325+
rawArgs: [],
326+
cmd: {} as never,
327+
} as never);
328+
expect(process.exitCode).toBe(1);
329+
expect(fakeLogs.error.some((l) => l.includes("Unknown machine: does-not-exist"))).toBe(true);
330+
});
278331
});

src/commands/copy.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { readdir, stat } from "node:fs/promises";
2-
import { join } from "node:path";
2+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
33
import { log } from "@clack/prompts";
44
import { defineCommand } from "citty";
55
import { applySingleArtifact, NoMatchingArtifactError } from "../agents/_apply";
@@ -33,9 +33,24 @@ export async function listMachines(vaultDir: string): Promise<string[]> {
3333

3434
/**
3535
* Enumerate every `.age` artifact beneath `<machineRoot>/<relDir>`, returned as
36-
* machine-root-relative logical paths (e.g. "claude/skills/foo.tar.age").
36+
* machine-root-relative logical paths (e.g. "claude/skills/foo.tar.age"). An
37+
* empty `relDir` enumerates the whole machine namespace.
3738
*/
38-
async function enumerateArtifacts(machineRoot: string, relDir: string): Promise<string[]> {
39+
export async function enumerateArtifacts(machineRoot: string, relDir: string): Promise<string[]> {
40+
// Path-traversal guard: a relDir with `..` segments would otherwise let
41+
// `join(machineRoot, relDir)` climb out of the namespace and enumerate `.age`
42+
// files elsewhere on disk (info disclosure via `ls`). Reject any relDir whose
43+
// resolved target is not contained within machineRoot. Only the entry relDir
44+
// is user-controlled — the recursive childRel values are always interior.
45+
// A `..` first segment means the resolved target escaped machineRoot. Match
46+
// the segment exactly (`..` alone or `..${sep}…`), not a bare `startsWith("..")`
47+
// which would also reject a legitimate in-namespace dir literally named `..foo`.
48+
const root = resolve(machineRoot);
49+
const containment = relative(root, resolve(root, relDir));
50+
if (containment === ".." || containment.startsWith(`..${sep}`) || isAbsolute(containment)) {
51+
return [];
52+
}
53+
3954
const out: string[] = [];
4055
async function walk(rel: string): Promise<void> {
4156
let entries: import("node:fs").Dirent[];
@@ -45,7 +60,8 @@ async function enumerateArtifacts(machineRoot: string, relDir: string): Promise<
4560
return;
4661
}
4762
for (const e of entries) {
48-
const childRel = `${rel}/${e.name}`;
63+
// Avoid a leading slash when rel is empty (whole-namespace enumeration).
64+
const childRel = rel ? `${rel}/${e.name}` : e.name;
4965
if (e.isDirectory()) await walk(childRel);
5066
else if (e.isFile() && e.name.endsWith(".age")) out.push(childRel);
5167
}

0 commit comments

Comments
 (0)