Skip to content
Merged
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
21 changes: 9 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,19 +49,16 @@ Authenticate with access keys:

```
$ configure --key tid_... --secret tsec_...
Available buckets:
my-bucket
shared-data
Mounted 2 bucket(s) at /. Run 'df' to list them.

Mounted my-bucket at /workspace

$ echo "hello" > greeting.txt
$ cat greeting.txt
/ $ ls
my-bucket shared-data
/ $ cd my-bucket
/my-bucket $ echo "hello" > greeting.txt
/my-bucket $ cat greeting.txt
hello
$ ls
greeting.txt
$ flush
Flushed 1 mount(s)
/my-bucket $ flush
Flushed 2 mount(s)
```

Or login with your Tigris account:
Expand All @@ -74,7 +71,7 @@ Open this URL in your browser:
Waiting for authorization... done!
Logged in as you@example.com

Mounted my-bucket at /workspace
Mounted 2 bucket(s) at /. Run 'df' to list them.
```

You can also pass credentials as flags:
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"main": "dist/index.js",
"types": "dist/index.d.ts",
"bin": {
"agent-shell": "dist/cli.js",
"tigris-agent-shell": "dist/cli.js"
},
"exports": {
Expand Down
2 changes: 1 addition & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ function printHelp() {
process.stdout.write(`Tigris Agent Shell — A virtual bash environment with a persistent filesystem backed by Tigris object storage

Usage:
tigris-agent-shell [options]
npx @tigrisdata/agent-shell [options]

Options:
--key <id> Access key ID
Expand Down
1 change: 1 addition & 0 deletions src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,6 @@ export function createTigrisCommands(config: TigrisConfig): Command[] {
}

export { createForkCommand, createForksListCommand } from "./fork.js";
export type { PresignOptions } from "./presign.js";
export { createPresignCommand } from "./presign.js";
export { createSnapshotCommand } from "./snapshot.js";
38 changes: 31 additions & 7 deletions src/commands/presign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,15 @@ function parsePresignArgs(args: string[]): {
return { expiresIn, operation };
}

export function createPresignCommand(config: TigrisConfig) {
return defineCommand("presign", async (args) => {
const path = args[0];
if (!path) {
export interface PresignOptions {
/** Resolve an absolute path to bucket + key from the mount table. */
resolveBucket?: (path: string) => { bucket: string; key: string } | null;
}

export function createPresignCommand(config: TigrisConfig, options?: PresignOptions) {
return defineCommand("presign", async (args, ctx) => {
const rawPath = args[0];
if (!rawPath) {
return {
stdout: "",
stderr: "presign: missing path argument\nUsage: presign <path> [--expires N] [--put]\n",
Expand All @@ -46,12 +51,31 @@ export function createPresignCommand(config: TigrisConfig) {
};
}

let resolved: { bucket: string; key: string } | null;
if (config.bucket) {
// Single-bucket mode — rawPath is relative to the bucket root
const key = rawPath.startsWith("/") ? rawPath.slice(1) : rawPath;
resolved = { bucket: config.bucket, key };
} else {
// Multi-bucket — resolve against cwd to find the mount
const absolutePath = rawPath.startsWith("/")
? rawPath
: `${ctx.cwd.replace(/\/$/, "")}/${rawPath}`;
resolved = options?.resolveBucket?.(absolutePath) ?? null;
}
if (!resolved) {
return {
stdout: "",
stderr: "presign: cannot determine bucket. cd into a mounted bucket first.\n",
exitCode: 1,
};
}

const { expiresIn, operation } = parsePresignArgs(args.slice(1));
const key = path.startsWith("/") ? path.slice(1) : path;
const result = await getPresignedUrl(key, {
const result = await getPresignedUrl(resolved.key, {
operation,
expiresIn,
config,
config: { ...config, bucket: resolved.bucket },
});

if ("error" in result) {
Expand Down
64 changes: 30 additions & 34 deletions src/repl/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export class ReplSession {
});

if (result.env?.PWD) {
this.cwd = result.env.PWD;
this.cwd = result.env.PWD.replace(/\/\/+/g, "/");
}

if (result.stdout) io.write(result.stdout);
Expand Down Expand Up @@ -115,7 +115,7 @@ export class ReplSession {
}
}

/** Shared: list buckets, auto-mount first, commit session. */
/** Shared: list buckets, mount all, commit session. */
private async listAndMountBuckets(
newConfig: TigrisConfig,
authMethod: "access-key" | "oauth",
Expand All @@ -139,26 +139,15 @@ export class ReplSession {
return;
}

io.write("Available buckets:\n");
for (const name of bucketNames) {
io.write(` ${name}\n`);
}

const first = bucketNames[0];
if (first) {
// Create shell with the first bucket so commands get the right config
Comment thread
cursor[bot] marked this conversation as resolved.
newConfig.bucket = first;
const mountPoint = `/${first}`;
const newShell = new TigrisShell(newConfig, { cwd: mountPoint });
this.commitSession(newConfig, newShell, authMethod);
this.cwd = mountPoint;
io.write(`\nMounted ${first} at ${mountPoint}\n`);
}
const newShell = new TigrisShell(newConfig, { cwd: "/" });
this.commitSession(newConfig, newShell, authMethod);
this.cwd = "/";

if (bucketNames.length > 1) {
io.write("\nTo mount additional buckets:\n");
io.write(" mount <bucket-name> <path>\n");
// Mount all buckets at /<name>
for (const name of bucketNames) {
newShell.mount(name, `/${name}`);
}
io.write(`Mounted ${bucketNames.length} bucket(s) at /. Run 'df' to list them.\n`);
}

/** Commit a new session — replace config, shell, reset cwd. */
Expand Down Expand Up @@ -350,9 +339,10 @@ export class ReplSession {
return;
}

io.write("Bucket Mounted on\n");
const col = Math.max("Bucket".length, ...mounts.map((m) => m.bucket.length)) + 2;
io.write(`${"Bucket".padEnd(col)}Mounted on\n`);
for (const m of mounts) {
io.write(`${m.bucket.padEnd(26)}${m.mountPoint}\n`);
io.write(`${m.bucket.padEnd(col)}${m.mountPoint}\n`);
}
}

Expand Down Expand Up @@ -411,19 +401,25 @@ export class ReplSession {
}

private handleHelp(io: ReplIO): void {
io.write("Commands:\n");
io.write(" login Login (OAuth)\n");
io.write("Session:\n");
io.write(" login Login with Tigris (OAuth)\n");
io.write(" configure --key <id> --secret <key> [--bucket <name>] [--endpoint <url>]\n");
io.write(" mount <bucket> <path> Mount a bucket\n");
io.write(" mount List mounts\n");
io.write(" umount <path> Unmount a path\n");
io.write(" df List mounts\n");
io.write(" flush [path] Flush to Tigris\n");
io.write(" whoami Show current session\n");
io.write(" logout Clear session\n");
io.write(" clear Clear screen\n");
io.write(" help Show this help\n");
io.write("\nAll other commands are executed as bash.\n");
io.write(" whoami Show current session\n");
io.write(" logout Clear session\n");
io.write("\nStorage:\n");
io.write(" mount <bucket> <path> Mount a bucket\n");
io.write(" umount <path> Unmount a path\n");
io.write(" df List mounts\n");
io.write(" flush [path] Flush changes to Tigris\n");
io.write(" presign <path> [--expires N] [--put] Generate a presigned URL\n");
io.write(" snapshot <bucket> [--name N] [--list] Create or list snapshots\n");
io.write(" fork <source> <name> [--snapshot V] Fork a bucket\n");
io.write(" forks <bucket> List forks\n");
io.write("\nShell:\n");
io.write(" clear Clear screen\n");
io.write(" help Show this help\n");
io.write("\nAll other input is executed as bash.\n");
io.write("Supported commands: https://github.com/vercel-labs/just-bash#supported-commands\n");
}

/** Whether a shell is configured and ready. */
Expand Down
35 changes: 29 additions & 6 deletions src/shell.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { BashExecResult } from "just-bash";
import { Bash, InMemoryFs, MountableFs } from "just-bash";
import { createTigrisCommands } from "./commands/index.js";
import { createForkCommand, createForksListCommand } from "./commands/fork.js";
import { createPresignCommand } from "./commands/presign.js";
import { createSnapshotCommand } from "./commands/snapshot.js";
import { TigrisAdapter } from "./fs/tigris-adapter.js";
import type { ShellOptions, TigrisConfig } from "./types.js";
import { validateConfig } from "./types.js";
Expand Down Expand Up @@ -39,15 +41,18 @@ export class TigrisShell {
this.mount(config.bucket, cwd);
}

const commandConfig = config.bucket
? { ...config, bucket: config.bucket }
: { ...config, bucket: "" };

this.bash = new Bash({
fs: this.mountableFs,
cwd,
...(shellOptions?.env !== undefined && { env: shellOptions.env }),
customCommands: createTigrisCommands(commandConfig),
customCommands: [
createPresignCommand(config, {
resolveBucket: (path) => this.resolveBucketForPath(path),
}),
createSnapshotCommand(config),
createForkCommand(config),
createForksListCommand(config),
],
});
}

Expand Down Expand Up @@ -110,6 +115,24 @@ export class TigrisShell {
}
}

/** Resolve an absolute path to its bucket and object key. */
private resolveBucketForPath(absolutePath: string): { bucket: string; key: string } | null {
// Normalize double slashes (e.g. //bucket from cd ../bucket)
const normalized = absolutePath.replace(/\/\/+/g, "/");
// Find the longest matching mount point
let best: MountEntry | null = null;
for (const m of this.mounts) {
if (normalized === m.mountPoint || normalized.startsWith(`${m.mountPoint}/`)) {
if (!best || m.mountPoint.length > best.mountPoint.length) {
best = m;
}
}
}
if (!best) return null;
const key = normalized.slice(best.mountPoint.length + 1); // strip mount + "/"
return { bucket: best.bucket, key };
}

/** Access the underlying just-bash instance. */
get engine(): Bash {
return this.bash;
Expand Down