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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,20 @@ Stream larger reads:
putio transfers list --page-all --output ndjson
```

Call a JSON-compatible TypeScript SDK operation that does not have a dedicated command:

```bash
putio sdk list --output json
putio sdk call --operation files.get --args '[42]' --dry-run --output json
putio sdk call --json '{"operation":"files.get","args":[42]}' --execute --output json
```

`sdk call` treats every operation as potentially mutating. It requires exactly one of `--dry-run`
or `--execute`, resolves auth through the normal profile selection, and only traverses own SDK
properties. `sdk list` marks operations requiring runtime objects or binary output—and operations
whose positional or scalar credentials cannot be safely redacted—as unsupported. Supported keyed
credential fields and token-bearing URLs are redacted in plans and results.

## Tips

- Use `--output json` when you want a stable machine-readable contract for scripts, agents, and automation.
Expand Down
2 changes: 1 addition & 1 deletion skills/putio-cli/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: putio-cli
description: Use when an agent needs to operate the put.io CLI as a consumer for put.io authentication, device approval, files, downloads, transfers, or cloud storage tasks, including discovering commands with `putio describe --output json`, authenticating with named profiles, reading stable JSON or NDJSON output, narrowing responses with `--fields`, paging safely with `--page-all`, and previewing writes with `--dry-run` and raw `--json`.
description: Use when an agent needs to operate the put.io CLI as a consumer for put.io authentication, device approval, files, downloads, transfers, cloud storage, or generic TypeScript SDK tasks, including discovering commands with `putio describe --output json`, authenticating with named profiles, reading stable JSON or NDJSON output, narrowing responses with `--fields`, paging safely with `--page-all`, and previewing writes with `--dry-run` and raw `--json`.
---

# putio-cli
Expand Down
8 changes: 8 additions & 0 deletions skills/putio-cli/references/discovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ Structured output defaults:

Use `automation` to confirm concrete support such as dry-run on writes, raw JSON input, field selection, streaming reads, redaction, and untrusted-text annotations. Treat missing features as a real contract gap instead of assuming they exist.

When the required API operation has no dedicated command, inspect the pinned TypeScript SDK surface:

```bash
putio sdk list --output json
```

Only operation paths in `operations` are eligible for `sdk call`. Entries in `unsupported` need runtime values, produce binary data, or use positional or scalar credentials that cannot be safely redacted. Supported keyed credential fields and token-bearing URLs are redacted in plans and results.

Versioning rules:

- The skill library follows the CLI contract exposed by `putio describe --output json`.
Expand Down
1 change: 1 addition & 0 deletions skills/putio-cli/references/guardrails.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Input safety notes:
- resource identifiers reject query fragments and traversal-like segments
- field selectors reject nested paths and malformed tokens
- name-like inputs reject control characters and traversal-like segments
- generic SDK operation paths resolve only listed enumerable own data properties, reject prototype traversal and accessors, accept positional JSON values only, exclude unsafe positional or scalar credentials, and redact supported keyed secrets and token-bearing URLs
- local upload paths reject control characters and must resolve to readable regular files

Output safety notes:
Expand Down
3 changes: 3 additions & 0 deletions skills/putio-cli/references/writes.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,14 @@ putio files mkdir --json '{"name":"Projects","parent_id":9}' --output json
putio files upload --json '{"path":"./movie.mp4","parent_id":42}' --output json
putio files start-from reset --json '{"file_id":42}' --output json
putio transfers add --json '[{"url":"https://example.com/file.torrent"}]' --output json
putio sdk call --json '{"operation":"files.get","args":[42]}' --dry-run --output json
putio sdk call --json '{"operation":"files.get","args":[42]}' --execute --output json
```

Rules:

- Prefer `--json` over translating through many bespoke flags.
- Prefer `--dry-run` before side effects.
- Re-check schema-required keys in `describe` instead of guessing names.
- Treat every `sdk call` operation as potentially mutating and inspect its dry-run before `--execute`.
- `files upload` validates that `path` resolves to a readable regular file before dry-run or execution.
7 changes: 7 additions & 0 deletions src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ describe("cli argv parsing", () => {
}>;
const mkdir = commands.find((entry) => entry.command === "files mkdir");
const deleteFiles = commands.find((entry) => entry.command === "files delete");
const sdkCall = commands.find((entry) => entry.command === "sdk call");
const upload = commands.find((entry) => entry.command === "files upload");
const authApprove = commands.find((entry) => entry.command === "auth approve");
const startFromSet = commands.find((entry) => entry.command === "files start-from set");
Expand All @@ -202,6 +203,12 @@ describe("cli argv parsing", () => {
expect.objectContaining({ name: "skip_trash", required: false }),
]),
);
expect(sdkCall?.input.json?.properties).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "args", required: false }),
expect.objectContaining({ name: "operation", required: true }),
]),
);
expect(upload?.input.json?.properties).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "path", required: true }),
Expand Down
2 changes: 2 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { brandCommand, versionCommand } from "./commands/brand.js";
import { downloadLinksCommand } from "./commands/download-links.js";
import { eventsCommand } from "./commands/events.js";
import { filesCommand, searchCommand } from "./commands/files.js";
import { sdkCommand } from "./commands/sdk.js";
import { translate } from "./i18n/index.js";
import type { CliConfig } from "./internal/config.js";
import { transfersCommand } from "./commands/transfers.js";
Expand Down Expand Up @@ -41,6 +42,7 @@ const command = Command.make("putio", {}, () => Console.log(translate("cli.root.
eventsCommand,
filesCommand,
searchCommand,
sdkCommand,
transfersCommand,
]),
);
Expand Down
159 changes: 159 additions & 0 deletions src/command-paths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1356,6 +1356,165 @@ describe("cli command paths", () => {
);
});

it("invokes a JSON-compatible sdk operation with explicit execution", async () => {
await expect(
runCliInTest([
"putio",
"sdk",
"call",
"--operation",
"files.list",
"--args",
'[0,{"per_page":10}]',
"--execute",
"--output",
"json",
]),
).resolves.toBeUndefined();

expect(mocks.listFilesMock).toHaveBeenCalledWith(0, { per_page: 10 });
expect(mocks.writeOutputMock).toHaveBeenCalledWith(
{
operation: "files.list",
result: expect.objectContaining({ total: 1 }),
},
"json",
expect.any(Function),
);
});

it("previews a raw-json sdk operation without authentication or invocation", async () => {
await expect(
runCliInTest([
"putio",
"sdk",
"call",
"--json",
'{"operation":"files.list","args":[0]}',
"--dry-run",
"--output",
"json",
]),
).resolves.toBeUndefined();

expect(mocks.withAuthedSdkMock).not.toHaveBeenCalled();
expect(mocks.listFilesMock).not.toHaveBeenCalled();
expect(mocks.writeOutputMock).toHaveBeenCalledWith(
{
command: "sdk call",
dryRun: true,
request: {
args: [0],
operation: "files.list",
},
},
"json",
expect.any(Function),
);
});

it("requires explicit sdk call execution consent", async () => {
await expect(
runCliInTest(["putio", "sdk", "call", "--operation", "files.list", "--output", "json"]),
).rejects.toMatchObject({
message: "Choose exactly one of `sdk call --dry-run` or `sdk call --execute`.",
});

expect(mocks.withAuthedSdkMock).not.toHaveBeenCalled();
});

it("rejects secret-bearing sdk operations before dry-run output", async () => {
await expect(
runCliInTest([
"putio",
"sdk",
"call",
"--operation",
"auth.validateToken",
"--args",
'["secret-token"]',
"--dry-run",
"--output",
"json",
]),
).rejects.toMatchObject({
message:
"SDK operation `auth.validateToken` is not JSON-callable: authentication operations can expose credentials or approval codes.",
});

expect(mocks.writeOutputMock).not.toHaveBeenCalled();
expect(mocks.withAuthedSdkMock).not.toHaveBeenCalled();
});

it("strictly redacts sentinel-looking sdk secrets in dry-run plans", async () => {
await expect(
runCliInTest([
"putio",
"sdk",
"call",
"--operation",
"files.list",
"--args",
'[0,{"password":"null"}]',
"--dry-run",
"--output",
"json",
]),
).resolves.toBeUndefined();

expect(mocks.writeOutputMock).toHaveBeenCalledWith(
{
command: "sdk call",
dryRun: true,
request: {
args: [0, { password: "[REDACTED]" }],
operation: "files.list",
},
},
"json",
expect.any(Function),
);
expect(mocks.withAuthedSdkMock).not.toHaveBeenCalled();
});

it("strictly redacts sentinel-looking sdk secrets in execution results", async () => {
mocks.listFilesMock.mockImplementationOnce(() =>
Effect.succeed({
download_token: "null",
files: [],
total: 0,
}),
);

await expect(
runCliInTest([
"putio",
"sdk",
"call",
"--operation",
"files.list",
"--args",
"[0]",
"--execute",
"--output",
"json",
]),
).resolves.toBeUndefined();

expect(mocks.writeOutputMock).toHaveBeenCalledWith(
{
operation: "files.list",
result: {
download_token: "[REDACTED]",
files: [],
total: 0,
},
},
"json",
expect.any(Function),
);
});

it("selects top-level file list fields for json output", async () => {
await expect(
runCliInTest(["putio", "files", "list", "--fields", "files,total", "--output", "json"]),
Expand Down
Loading