@agfs/cli 0.1.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.
@@ -0,0 +1,96 @@
1
+ import { Command } from "commander";
2
+ import { AgfsClient } from "../lib/client";
3
+ import { renderEntries, renderTree } from "../lib/format";
4
+
5
+ export function registerFsCommands(program: Command) {
6
+ program
7
+ .command("ls")
8
+ .description("List the direct children at a remote path")
9
+ .argument("[path]", "Remote path", "/")
10
+ .action(async (pathname: string) => {
11
+ const client = await AgfsClient.fromConfig();
12
+ const result = await client.list(pathname);
13
+ console.log(renderEntries(result.entries));
14
+ });
15
+
16
+ program
17
+ .command("tree")
18
+ .description("Render a tree view of a remote folder")
19
+ .argument("[path]", "Remote path", "/")
20
+ .action(async (pathname: string) => {
21
+ const client = await AgfsClient.fromConfig();
22
+ const result = await client.tree(pathname);
23
+ console.log(renderTree(result.tree));
24
+ });
25
+
26
+ program
27
+ .command("mkdir")
28
+ .description("Create a folder path")
29
+ .argument("<path>", "Remote path")
30
+ .action(async (pathname: string) => {
31
+ const client = await AgfsClient.fromConfig();
32
+ await client.mkdir(pathname);
33
+ console.log(`Created ${pathname}`);
34
+ });
35
+
36
+ program
37
+ .command("mv")
38
+ .description("Move or rename a file or folder")
39
+ .argument("<from>", "Current path")
40
+ .argument("<to>", "Destination path")
41
+ .action(async (from: string, to: string) => {
42
+ const client = await AgfsClient.fromConfig();
43
+ await client.move(from, to);
44
+ console.log(`Moved ${from} -> ${to}`);
45
+ });
46
+
47
+ program
48
+ .command("rm")
49
+ .description("Delete a file or folder")
50
+ .argument("<path>", "Remote path")
51
+ .option("--recursive", "Delete folders recursively")
52
+ .action(async (pathname: string, options: { recursive?: boolean }) => {
53
+ const client = await AgfsClient.fromConfig();
54
+ await client.remove(pathname, Boolean(options.recursive));
55
+ console.log(`Removed ${pathname}`);
56
+ });
57
+
58
+ program
59
+ .command("upload")
60
+ .description("Upload a local file into AGFS")
61
+ .argument("<localPath>", "Local file path")
62
+ .argument("[remotePath]", "Remote destination path")
63
+ .option("--share <ttl>", "Create a preview link after upload")
64
+ .action(async (localPath: string, remotePath: string | undefined, options: { share?: string }) => {
65
+ const client = await AgfsClient.fromConfig();
66
+ const destination = remotePath ?? `/${localPath.split("/").at(-1)}`;
67
+ await client.upload(localPath, destination);
68
+ console.log(`Uploaded ${destination}`);
69
+ if (options.share) {
70
+ const share = await client.share(destination, options.share);
71
+ console.log(`Share URL: ${share.share.url}`);
72
+ }
73
+ });
74
+
75
+ program
76
+ .command("download")
77
+ .description("Download a file or folder from AGFS")
78
+ .argument("<remotePath>", "Remote file or folder path")
79
+ .argument("[localPath]", "Destination path on disk")
80
+ .action(async (remotePath: string, localPath?: string) => {
81
+ const client = await AgfsClient.fromConfig();
82
+ const destination = await client.download(remotePath, localPath);
83
+ console.log(`Saved ${destination}`);
84
+ });
85
+
86
+ program
87
+ .command("share")
88
+ .description("Generate a preview URL for a file")
89
+ .argument("<remotePath>", "Remote file path")
90
+ .option("--ttl <ttl>", "Link lifetime", "15m")
91
+ .action(async (remotePath: string, options: { ttl: string }) => {
92
+ const client = await AgfsClient.fromConfig();
93
+ const result = await client.share(remotePath, options.ttl);
94
+ console.log(result.share.url);
95
+ });
96
+ }
package/src/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { registerAuthCommands } from "./commands/auth";
4
+ import { registerFsCommands } from "./commands/fs";
5
+
6
+ const program = new Command()
7
+ .name("agfs")
8
+ .description("AgentFilesystem CLI")
9
+ .version("0.1.0");
10
+
11
+ registerAuthCommands(program);
12
+ registerFsCommands(program);
13
+
14
+ program.parseAsync(process.argv).catch((error: unknown) => {
15
+ console.error(error instanceof Error ? error.message : "Command failed");
16
+ process.exitCode = 1;
17
+ });
@@ -0,0 +1,307 @@
1
+ import { createReadStream, createWriteStream } from "node:fs";
2
+ import { mkdir, stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { Transform } from "node:stream";
5
+ import { lookup as lookupMime } from "mime-types";
6
+ import {
7
+ devicePollResponseSchema,
8
+ deviceStartResponseSchema,
9
+ listEntriesResponseSchema,
10
+ shareCreateResponseSchema,
11
+ shareListResponseSchema,
12
+ successResponseSchema,
13
+ tokenCreateResponseSchema,
14
+ tokenListResponseSchema,
15
+ treeEntriesResponseSchema,
16
+ uploadIntentSchema,
17
+ whoAmIResponseSchema,
18
+ } from "@agfs/contracts";
19
+ import { getResolvedBaseUrl, getResolvedToken, readConfig } from "./config";
20
+ import { flattenTree, getRemoteLeafName, joinRelativeDestination, resolveFileDestination, resolveFolderDestination } from "./download";
21
+ import { summarizeFolderDownload, TransferProgress } from "./progress";
22
+
23
+ async function parseError(response: Response) {
24
+ try {
25
+ const payload = await response.json();
26
+ return payload.error ?? response.statusText;
27
+ } catch {
28
+ return response.statusText;
29
+ }
30
+ }
31
+
32
+ export class AgfsClient {
33
+ constructor(
34
+ readonly baseUrl: string,
35
+ readonly token: string | null,
36
+ ) {}
37
+
38
+ static async fromConfig() {
39
+ const config = await readConfig();
40
+ return new AgfsClient(getResolvedBaseUrl(config), getResolvedToken(config));
41
+ }
42
+
43
+ private async request(pathname: string, init?: RequestInit) {
44
+ const headers = new Headers(init?.headers);
45
+ if (this.token) {
46
+ headers.set("authorization", `Bearer ${this.token}`);
47
+ }
48
+
49
+ const response = await fetch(`${this.baseUrl}${pathname}`, {
50
+ ...init,
51
+ headers,
52
+ });
53
+
54
+ if (!response.ok) {
55
+ throw new Error(await parseError(response));
56
+ }
57
+
58
+ return response;
59
+ }
60
+
61
+ async whoAmI() {
62
+ const response = await this.request("/api/v1/whoami");
63
+ return whoAmIResponseSchema.parse(await response.json());
64
+ }
65
+
66
+ async startDeviceLogin(clientName = "agfs cli") {
67
+ const response = await this.request("/api/v1/device/start", {
68
+ method: "POST",
69
+ headers: { "content-type": "application/json" },
70
+ body: JSON.stringify({ clientName }),
71
+ });
72
+ return deviceStartResponseSchema.parse(await response.json());
73
+ }
74
+
75
+ async pollDeviceLogin(deviceCode: string) {
76
+ const response = await this.request("/api/v1/device/poll", {
77
+ method: "POST",
78
+ headers: { "content-type": "application/json" },
79
+ body: JSON.stringify({ deviceCode }),
80
+ });
81
+ return devicePollResponseSchema.parse(await response.json());
82
+ }
83
+
84
+ async list(pathname: string) {
85
+ const response = await this.request(`/api/v1/fs/list?path=${encodeURIComponent(pathname)}`);
86
+ return listEntriesResponseSchema.parse(await response.json());
87
+ }
88
+
89
+ async tree(pathname: string) {
90
+ const response = await this.request(`/api/v1/fs/tree?path=${encodeURIComponent(pathname)}`);
91
+ return treeEntriesResponseSchema.parse(await response.json());
92
+ }
93
+
94
+ async mkdir(pathname: string) {
95
+ const response = await this.request("/api/v1/fs/mkdir", {
96
+ method: "POST",
97
+ headers: { "content-type": "application/json" },
98
+ body: JSON.stringify({ path: pathname }),
99
+ });
100
+ return successResponseSchema.parse(await response.json());
101
+ }
102
+
103
+ async move(from: string, to: string) {
104
+ const response = await this.request("/api/v1/fs/move", {
105
+ method: "POST",
106
+ headers: { "content-type": "application/json" },
107
+ body: JSON.stringify({ from, to }),
108
+ });
109
+ return successResponseSchema.parse(await response.json());
110
+ }
111
+
112
+ async remove(pathname: string, recursive = false) {
113
+ const response = await this.request("/api/v1/fs/delete", {
114
+ method: "POST",
115
+ headers: { "content-type": "application/json" },
116
+ body: JSON.stringify({ path: pathname, recursive }),
117
+ });
118
+ return successResponseSchema.parse(await response.json());
119
+ }
120
+
121
+ async upload(localPath: string, remotePath: string) {
122
+ const fileStat = await stat(localPath);
123
+ const fileSize = fileStat.size;
124
+ const contentType = lookupMime(localPath) || "application/octet-stream";
125
+ const intentResponse = await this.request("/api/v1/fs/upload-intents", {
126
+ method: "POST",
127
+ headers: { "content-type": "application/json" },
128
+ body: JSON.stringify({
129
+ path: remotePath,
130
+ contentType,
131
+ size: fileSize,
132
+ }),
133
+ });
134
+ const intent = uploadIntentSchema.parse(await intentResponse.json());
135
+ const progress = new TransferProgress(`Upload ${path.basename(localPath)}`, fileSize);
136
+ let uploadedBytes = 0;
137
+ const progressStream = new Transform({
138
+ transform(chunk, _encoding, callback) {
139
+ uploadedBytes += chunk.length;
140
+ progress.update(uploadedBytes);
141
+ callback(null, chunk);
142
+ },
143
+ });
144
+
145
+ let uploadResponse: Response;
146
+ try {
147
+ uploadResponse = await fetch(intent.url, {
148
+ method: intent.method,
149
+ headers: {
150
+ ...intent.headers,
151
+ "Content-Length": String(fileSize),
152
+ },
153
+ body: createReadStream(localPath).pipe(progressStream),
154
+ duplex: "half",
155
+ });
156
+ if (!uploadResponse.ok) {
157
+ throw new Error(`R2 upload failed with status ${uploadResponse.status}`);
158
+ }
159
+ progress.complete();
160
+ } catch (error) {
161
+ progress.fail();
162
+ throw error;
163
+ }
164
+
165
+ const commitResponse = await this.request(`/api/v1/fs/uploads/${intent.uploadId}/commit`, {
166
+ method: "POST",
167
+ headers: { "content-type": "application/json" },
168
+ body: JSON.stringify({
169
+ etag: uploadResponse.headers.get("etag") ?? "uploaded",
170
+ }),
171
+ });
172
+
173
+ return await commitResponse.json();
174
+ }
175
+
176
+ private async writeRemoteFile(remotePath: string, localPath?: string) {
177
+ const response = await this.request(`/api/v1/fs/download?path=${encodeURIComponent(remotePath)}`);
178
+ let destination = resolveFileDestination(remotePath, localPath);
179
+ if (localPath) {
180
+ try {
181
+ const localStat = await stat(localPath);
182
+ if (localStat.isDirectory()) {
183
+ destination = path.join(localPath, getRemoteLeafName(remotePath));
184
+ }
185
+ } catch {
186
+ // Treat a missing path as the intended file destination.
187
+ }
188
+ }
189
+
190
+ await mkdir(path.dirname(destination), { recursive: true });
191
+ const totalBytes = Number(response.headers.get("content-length") ?? 0);
192
+ const progress = new TransferProgress(`Download ${path.basename(destination)}`, totalBytes || 0);
193
+ const body = response.body;
194
+ if (!body) {
195
+ progress.fail();
196
+ throw new Error(`No response body returned for ${remotePath}`);
197
+ }
198
+
199
+ const writer = createWriteStream(destination);
200
+ const reader = body.getReader();
201
+ let writtenBytes = 0;
202
+
203
+ try {
204
+ while (true) {
205
+ const { done, value } = await reader.read();
206
+ if (done) {
207
+ break;
208
+ }
209
+
210
+ writtenBytes += value.byteLength;
211
+ progress.update(totalBytes > 0 ? writtenBytes : Math.max(writtenBytes, 1));
212
+
213
+ await new Promise<void>((resolve, reject) => {
214
+ writer.write(Buffer.from(value), (error) => {
215
+ if (error) {
216
+ reject(error);
217
+ return;
218
+ }
219
+ resolve();
220
+ });
221
+ });
222
+ }
223
+
224
+ await new Promise<void>((resolve, reject) => {
225
+ writer.end((error) => {
226
+ if (error) {
227
+ reject(error);
228
+ return;
229
+ }
230
+ resolve();
231
+ });
232
+ });
233
+
234
+ if (totalBytes > 0) {
235
+ progress.complete();
236
+ } else {
237
+ progress.update(writtenBytes);
238
+ progress.complete();
239
+ }
240
+ } catch (error) {
241
+ progress.fail();
242
+ writer.destroy();
243
+ throw error;
244
+ }
245
+
246
+ return destination;
247
+ }
248
+
249
+ async download(remotePath: string, localPath?: string) {
250
+ let isFolder = false;
251
+ try {
252
+ await this.list(remotePath);
253
+ isFolder = true;
254
+ } catch {
255
+ isFolder = false;
256
+ }
257
+
258
+ if (!isFolder) {
259
+ return this.writeRemoteFile(remotePath, localPath);
260
+ }
261
+
262
+ const destinationRoot = resolveFolderDestination(remotePath, localPath);
263
+ await mkdir(destinationRoot, { recursive: true });
264
+
265
+ const tree = await this.tree(remotePath);
266
+ const flattened = flattenTree(tree.tree);
267
+
268
+ for (const directory of flattened.directories) {
269
+ await mkdir(joinRelativeDestination(destinationRoot, directory), { recursive: true });
270
+ }
271
+
272
+ for (const file of flattened.files) {
273
+ await this.writeRemoteFile(file.path, joinRelativeDestination(destinationRoot, file.relativePath));
274
+ }
275
+
276
+ console.error(summarizeFolderDownload(flattened.files.length, destinationRoot));
277
+ return destinationRoot;
278
+ }
279
+
280
+ async share(pathname: string, ttl = "15m") {
281
+ const response = await this.request("/api/v1/shares", {
282
+ method: "POST",
283
+ headers: { "content-type": "application/json" },
284
+ body: JSON.stringify({ path: pathname, ttl }),
285
+ });
286
+ return shareCreateResponseSchema.parse(await response.json());
287
+ }
288
+
289
+ async listTokens() {
290
+ const response = await this.request("/api/v1/tokens");
291
+ return tokenListResponseSchema.parse(await response.json());
292
+ }
293
+
294
+ async createToken(label: string, ttl?: string) {
295
+ const response = await this.request("/api/v1/tokens", {
296
+ method: "POST",
297
+ headers: { "content-type": "application/json" },
298
+ body: JSON.stringify({ label, ttl }),
299
+ });
300
+ return tokenCreateResponseSchema.parse(await response.json());
301
+ }
302
+
303
+ async listShares() {
304
+ const response = await this.request("/api/v1/shares");
305
+ return shareListResponseSchema.parse(await response.json());
306
+ }
307
+ }
@@ -0,0 +1,31 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { getConfigPath, readConfig, writeConfig } from "./config";
6
+
7
+ let tempDir = "";
8
+ const previousXdg = process.env.XDG_CONFIG_HOME;
9
+
10
+ describe("config persistence", () => {
11
+ beforeEach(async () => {
12
+ tempDir = await mkdtemp(path.join(os.tmpdir(), "agfs-cli-"));
13
+ process.env.XDG_CONFIG_HOME = tempDir;
14
+ });
15
+
16
+ afterEach(async () => {
17
+ process.env.XDG_CONFIG_HOME = previousXdg;
18
+ if (tempDir) {
19
+ await rm(tempDir, { recursive: true, force: true });
20
+ }
21
+ });
22
+
23
+ it("writes and reads config from XDG config home", async () => {
24
+ await writeConfig({ baseUrl: "https://example.com", token: "secret" });
25
+ expect(getConfigPath()).toContain(tempDir);
26
+ await expect(readConfig()).resolves.toEqual({
27
+ baseUrl: "https://example.com",
28
+ token: "secret",
29
+ });
30
+ });
31
+ });
@@ -0,0 +1,42 @@
1
+ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ export interface CliConfig {
6
+ baseUrl?: string;
7
+ token?: string;
8
+ }
9
+
10
+ function getConfigDir() {
11
+ return process.env.XDG_CONFIG_HOME
12
+ ? path.join(process.env.XDG_CONFIG_HOME, "agfs")
13
+ : path.join(os.homedir(), ".config", "agfs");
14
+ }
15
+
16
+ export function getConfigPath() {
17
+ return path.join(getConfigDir(), "config.json");
18
+ }
19
+
20
+ export async function readConfig(): Promise<CliConfig> {
21
+ try {
22
+ const content = await readFile(getConfigPath(), "utf8");
23
+ return JSON.parse(content) as CliConfig;
24
+ } catch {
25
+ return {};
26
+ }
27
+ }
28
+
29
+ export async function writeConfig(config: CliConfig) {
30
+ const filePath = getConfigPath();
31
+ await mkdir(path.dirname(filePath), { recursive: true });
32
+ await writeFile(filePath, JSON.stringify(config, null, 2), "utf8");
33
+ await chmod(filePath, 0o600);
34
+ }
35
+
36
+ export function getResolvedBaseUrl(config: CliConfig) {
37
+ return (process.env.AGFS_BASE_URL ?? config.baseUrl ?? "https://agfs.dev").replace(/\/+$/, "");
38
+ }
39
+
40
+ export function getResolvedToken(config: CliConfig) {
41
+ return process.env.AGFS_TOKEN ?? config.token ?? null;
42
+ }
@@ -0,0 +1,71 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { flattenTree, getRemoteLeafName, joinRelativeDestination, resolveFolderDestination } from "./download";
3
+
4
+ describe("getRemoteLeafName", () => {
5
+ it("uses the final segment for normal paths", () => {
6
+ expect(getRemoteLeafName("/screenshots/shot.png")).toBe("shot.png");
7
+ expect(getRemoteLeafName("/screenshots")).toBe("screenshots");
8
+ });
9
+
10
+ it("uses agfs-root for root downloads", () => {
11
+ expect(getRemoteLeafName("/")).toBe("agfs-root");
12
+ });
13
+ });
14
+
15
+ describe("flattenTree", () => {
16
+ it("separates directories and files while preserving relative paths", () => {
17
+ const result = flattenTree([
18
+ {
19
+ id: "dir_1",
20
+ path: "/screenshots",
21
+ name: "screenshots",
22
+ kind: "folder",
23
+ size: null,
24
+ children: [
25
+ {
26
+ id: "file_1",
27
+ path: "/screenshots/a.png",
28
+ name: "a.png",
29
+ kind: "file",
30
+ size: 12,
31
+ },
32
+ {
33
+ id: "dir_2",
34
+ path: "/screenshots/nested",
35
+ name: "nested",
36
+ kind: "folder",
37
+ size: null,
38
+ children: [
39
+ {
40
+ id: "file_2",
41
+ path: "/screenshots/nested/b.png",
42
+ name: "b.png",
43
+ kind: "file",
44
+ size: 18,
45
+ },
46
+ ],
47
+ },
48
+ ],
49
+ },
50
+ ] as any);
51
+
52
+ expect(result.directories).toEqual(["screenshots", "screenshots/nested"]);
53
+ expect(result.files).toEqual([
54
+ {
55
+ path: "/screenshots/a.png",
56
+ relativePath: "screenshots/a.png",
57
+ },
58
+ {
59
+ path: "/screenshots/nested/b.png",
60
+ relativePath: "screenshots/nested/b.png",
61
+ },
62
+ ]);
63
+ });
64
+ });
65
+
66
+ describe("destination helpers", () => {
67
+ it("uses the provided folder destination as-is", () => {
68
+ expect(resolveFolderDestination("/screenshots", "./downloads")).toBe("./downloads");
69
+ expect(joinRelativeDestination("./downloads", "screenshots/a.png")).toBe("downloads/screenshots/a.png");
70
+ });
71
+ });
@@ -0,0 +1,58 @@
1
+ import path from "node:path";
2
+ import type { FsTreeNode } from "@agfs/contracts";
3
+
4
+ export interface FlattenedTree {
5
+ directories: string[];
6
+ files: Array<{
7
+ path: string;
8
+ relativePath: string;
9
+ }>;
10
+ }
11
+
12
+ export function getRemoteLeafName(remotePath: string): string {
13
+ const normalized = remotePath.replace(/\/+$/, "");
14
+ if (!normalized || normalized === "/") {
15
+ return "agfs-root";
16
+ }
17
+
18
+ return normalized.split("/").filter(Boolean).at(-1) ?? "agfs-root";
19
+ }
20
+
21
+ export function flattenTree(nodes: FsTreeNode[]): FlattenedTree {
22
+ const directories: string[] = [];
23
+ const files: Array<{ path: string; relativePath: string }> = [];
24
+
25
+ function walk(items: FsTreeNode[], parentSegments: string[]) {
26
+ for (const item of items) {
27
+ const segments = [...parentSegments, item.name];
28
+ const relativePath = segments.join("/");
29
+
30
+ if (item.kind === "folder") {
31
+ directories.push(relativePath);
32
+ walk((item.children ?? []) as FsTreeNode[], segments);
33
+ continue;
34
+ }
35
+
36
+ files.push({
37
+ path: item.path,
38
+ relativePath,
39
+ });
40
+ }
41
+ }
42
+
43
+ walk(nodes, []);
44
+
45
+ return { directories, files };
46
+ }
47
+
48
+ export function resolveFolderDestination(remotePath: string, localPath?: string): string {
49
+ return localPath ?? getRemoteLeafName(remotePath);
50
+ }
51
+
52
+ export function resolveFileDestination(remotePath: string, localPath?: string): string {
53
+ return localPath ?? getRemoteLeafName(remotePath);
54
+ }
55
+
56
+ export function joinRelativeDestination(root: string, relativePath: string): string {
57
+ return path.join(root, ...relativePath.split("/"));
58
+ }
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { renderEntries, renderTree } from "./format";
3
+
4
+ describe("renderEntries", () => {
5
+ it("renders a tabular listing", () => {
6
+ expect(
7
+ renderEntries([
8
+ {
9
+ id: "1",
10
+ ownerId: "user_1",
11
+ parentPath: "/",
12
+ path: "/shots",
13
+ name: "shots",
14
+ kind: "folder",
15
+ size: null,
16
+ contentType: null,
17
+ etag: null,
18
+ createdAt: new Date().toISOString(),
19
+ updatedAt: new Date().toISOString(),
20
+ },
21
+ ]),
22
+ ).toContain("/shots\tfolder\tfolder");
23
+ });
24
+ });
25
+
26
+ describe("renderTree", () => {
27
+ it("renders nested branches", () => {
28
+ expect(
29
+ renderTree([
30
+ {
31
+ id: "folder_1",
32
+ path: "/shots",
33
+ name: "shots",
34
+ kind: "folder",
35
+ size: null,
36
+ children: [
37
+ {
38
+ id: "file_1",
39
+ path: "/shots/a.png",
40
+ name: "a.png",
41
+ kind: "file",
42
+ size: 128,
43
+ },
44
+ ],
45
+ },
46
+ ]),
47
+ ).toContain("shots/");
48
+ });
49
+ });