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
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ Run `tigris help` to see all available commands, or `tigris <command> help` for
| `tigris objects` (o) | Low-level object operations for listing, downloading, uploading, and deleting individual objects in a bucket |
| `tigris access-keys` (keys) | Create, list, inspect, delete, and assign roles to access keys. Access keys are credentials used for programmatic API access |
| `tigris iam` | Identity and Access Management - manage policies, users, and permissions |
| `tigris project` | Project scaffolding helpers for getting started with Tigris |

---

Expand Down Expand Up @@ -1448,6 +1449,38 @@ tigris iam users remove user@example.com --yes
tigris iam users remove user@example.com,user@example.net --yes
```

### `tigris project`

Project scaffolding helpers for getting started with Tigris

| Command | Description |
|---------|-------------|
| `tigris project setup` | Set up a Tigris project — create a bucket, an Editor-scoped access key, and write a .env for the @tigrisdata/storage package |

#### `tigris project setup`

Set up a Tigris project — create a bucket, an Editor-scoped access key, and write a .env for the @tigrisdata/storage package

```
tigris project setup [flags]
```

| Flag | Description |
|------|-------------|
| `-b, --bucket` | Name of the bucket to create (default: a randomly generated name) |
| `-a, --access` | Access level for the bucket (default: private) |
| `-l, --locations` | Location(s) for the bucket, comma-separated (default: global) |
| `--env-file` | Path to the .env file to write (default: .env) |
| `--force` | Overwrite the .env file if it already exists |
| `--skip-agent-setup` | Skip installing Tigris agent skills |
| `--skip-install` | Skip installing the @tigrisdata/storage and @tigrisdata/agent-kit npm packages |

**Examples:**
```bash
tigris project setup
tigris project setup --bucket my-app --access public
```

## License

MIT
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@
"enquirer": "^2.4.1",
"jose": "^6.2.3",
"open": "^11.0.0",
"unique-names-generator": "^4.7.1",
"yaml": "^2.8.3"
},
"devDependencies": {
Expand Down
149 changes: 149 additions & 0 deletions src/lib/project/agent-setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* Agent skill installation for `tigris project setup`.
*
* Fetches the skills repo tarball and installs the Tigris storage skills into
* the project at .agents/skills, with per-skill symlinks from .claude/skills so
* Claude Code (and other skill-aware agents) pick them up. We intentionally do
* NOT install the Claude Code plugin from here: that mutates global config and
* can't take effect inside the same Claude session that launched setup.
*
* Everything here is best-effort: failures are reported but never abort the
* surrounding project setup.
*/

import {
cp,
lstat,
mkdir,
mkdtemp,
rm,
symlink,
writeFile,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, relative } from 'node:path';

import { run } from './exec.js';

const SKILLS_TARBALL_URL =
'https://codeload.github.com/tigrisdata/skills/tar.gz/refs/heads/main';
// Top-level directory inside the tarball (GitHub names it <repo>-<ref>).
const SKILLS_TARBALL_ROOT = 'skills-main';

/**
* Tigris storage-relevant skills to install. Generic skills in the repo
* (conventional-commits, go-table-driven-tests, language SDKs, …) are skipped.
*/
export const STORAGE_SKILLS = [
'file-storage',
'tigris-agent-kit',
'tigris-backup-export',
'tigris-bucket-management',
'tigris-egress-optimizer',
'tigris-image-optimization',
'tigris-lifecycle-management',
'tigris-object-operations',
];

export type AgentSetupResult = {
mode: 'skills';
installed: string[];
error?: string;
};

/** lstat the path, returning null if it doesn't exist. */
async function lstatOrNull(path: string) {
try {
return await lstat(path);
} catch {
return null;
}
}

/**
* Copy the allowlisted storage skills from an extracted skills directory into
* the project's .agents/skills, then create per-skill symlinks under
* .claude/skills (.claude/skills/<name> -> ../../.agents/skills/<name>).
*
* Returns the names of the skills that were installed. An existing real (non-
* symlink) entry in .claude/skills is left untouched rather than clobbered.
*/
export async function installSkillsFromDir(
sourceSkillsDir: string,
projectDir: string
): Promise<string[]> {
const agentsSkillsDir = join(projectDir, '.agents', 'skills');
const claudeSkillsDir = join(projectDir, '.claude', 'skills');
await mkdir(agentsSkillsDir, { recursive: true });
await mkdir(claudeSkillsDir, { recursive: true });

const installed: string[] = [];

for (const name of STORAGE_SKILLS) {
const src = join(sourceSkillsDir, name);
const srcStat = await lstatOrNull(src);
if (!srcStat?.isDirectory()) continue;

// Refresh the canonical copy under .agents/skills.
const dest = join(agentsSkillsDir, name);
await rm(dest, { recursive: true, force: true });
await cp(src, dest, { recursive: true });
installed.push(name);

// Link it into .claude/skills with a relative target.
const link = join(claudeSkillsDir, name);
const existing = await lstatOrNull(link);
if (existing?.isSymbolicLink()) {
await rm(link);
} else if (existing) {
// A real directory/file lives here — don't clobber it.
continue;
}
await symlink(relative(claudeSkillsDir, dest), link);
}

return installed;
}

/**
* Download and extract the skills tarball, then install storage skills into the
* project. Uses the system `tar` to unpack.
*/
async function installSkillsFromTarball(projectDir: string): Promise<string[]> {
const tmp = await mkdtemp(join(tmpdir(), 'tigris-skills-'));
try {
const res = await fetch(SKILLS_TARBALL_URL);
if (!res.ok) {
throw new Error(`Failed to download skills tarball (HTTP ${res.status})`);
}
const tarPath = join(tmp, 'skills.tar.gz');
await writeFile(tarPath, Buffer.from(await res.arrayBuffer()));
await run('tar', ['-xzf', tarPath, '-C', tmp]);

const sourceSkillsDir = join(tmp, SKILLS_TARBALL_ROOT, 'skills');
if (!(await lstatOrNull(sourceSkillsDir))) {
throw new Error('Unexpected skills tarball layout');
}
return await installSkillsFromDir(sourceSkillsDir, projectDir);
} finally {
await rm(tmp, { recursive: true, force: true });
}
}

/**
* Install Tigris skills for the project. Best-effort: any failure is captured
* in the returned result rather than thrown.
*/
export async function setupAgentResources(
projectDir: string,
options: { quiet?: boolean } = {}
): Promise<AgentSetupResult> {
void options; // reserved for future flags; no per-mode behavior today
try {
const installed = await installSkillsFromTarball(projectDir);
return { mode: 'skills', installed };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { mode: 'skills', installed: [], error: message };
}
}
35 changes: 35 additions & 0 deletions src/lib/project/exec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Small async process runner shared by the `tigris project setup` helpers.
*/

import { spawn } from 'node:child_process';

export const COMMAND_TIMEOUT_MS = 120_000;

/**
* Run a command to completion without blocking the event loop. Rejects on a
* non-zero exit, spawn error (e.g. command not found), or timeout.
*/
export function run(
command: string,
args: string[],
options: { stdio?: 'inherit' | 'ignore'; cwd?: string } = {}
): Promise<void> {
const { stdio = 'ignore', cwd } = options;
return new Promise((resolve, reject) => {
const child = spawn(command, args, { stdio, cwd });
const timer = setTimeout(() => {
child.kill();
reject(new Error(`${command} timed out`));
}, COMMAND_TIMEOUT_MS);
child.once('error', (err) => {
clearTimeout(timer);
reject(err);
});
child.once('close', (code) => {
clearTimeout(timer);
if (code === 0) resolve();
else reject(new Error(`${command} exited with code ${code}`));
});
});
}
41 changes: 41 additions & 0 deletions src/lib/project/packages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* NPM package installation for `tigris project setup`.
*
* Installs the runtime packages a freshly set-up Tigris project needs:
* `@tigrisdata/storage` (the storage SDK the generated .env targets) and
* `@tigrisdata/agent-kit`. Best-effort: a missing `npm`, no package.json, or a
* failed install is reported but never aborts the surrounding project setup.
*/

import { run } from './exec.js';

/** Packages installed into the project by default. */
export const PROJECT_PACKAGES = [
'@tigrisdata/storage',
'@tigrisdata/agent-kit',
];

export type PackageInstallResult = {
installed: string[];
error?: string;
};

/**
* Run `npm install <packages>` in the project directory. Any failure is
* captured in the returned result rather than thrown.
*/
export async function installProjectPackages(
projectDir: string,
options: { quiet?: boolean } = {}
): Promise<PackageInstallResult> {
try {
await run('npm', ['install', ...PROJECT_PACKAGES], {
cwd: projectDir,
stdio: options.quiet ? 'ignore' : 'inherit',
});
return { installed: [...PROJECT_PACKAGES] };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { installed: [], error: message };
}
}
Loading