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
482 changes: 482 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
"name": "plotlink",
"version": "0.1.0",
"private": true,
"workspaces": [
"packages/*"
],
"scripts": {
"dev": "next dev",
"build": "next build",
Expand Down
27 changes: 27 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"name": "plotlink-cli",
"version": "0.1.0",
"description": "CLI for the PlotLink protocol — create storylines, chain plots, register agents, and claim royalties.",
"type": "module",
"bin": {
"plotlink": "./dist/index.js"
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@plotlink/sdk": "*",
"@supabase/supabase-js": "^2.49.4",
"commander": "^13.1.0",
"viem": "^2.47.2"
},
"devDependencies": {
"tsup": "^8.4.0",
"typescript": "^5"
},
"license": "MIT"
}
72 changes: 72 additions & 0 deletions packages/cli/src/commands/agent-register.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { Command } from "commander";
import type { Address } from "viem";
import { buildClient } from "../sdk.js";

export function registerAgentRegister(program: Command): void {
const agent = program
.command("agent")
.description("Agent identity commands");

agent
.command("register")
.description("Register an AI agent on the ERC-8004 registry and set its wallet")
.requiredOption("-n, --name <name>", "Agent display name")
.requiredOption("-d, --description <desc>", "Short description of the agent")
.requiredOption("-g, --genre <genre>", "Primary genre the agent writes in")
.requiredOption("-m, --model <model>", "LLM model identifier (e.g. \"Claude Opus 4\")")
.option("-w, --wallet <address>", "Agent wallet address (defaults to the configured wallet)")
.option("--skip-wallet", "Skip wallet binding step", false)
.action(
async (opts: {
name: string;
description: string;
genre: string;
model: string;
wallet?: string;
skipWallet?: boolean;
}) => {
try {
// Resolve wallet key from env BEFORE spending gas on registration
let walletKey: string | undefined;
if (opts.wallet && !opts.skipWallet) {
walletKey = process.env.PLOTLINK_AGENT_WALLET_KEY;
if (!walletKey) {
console.error(
"Error: PLOTLINK_AGENT_WALLET_KEY env var is required when --wallet is set.\n" +
"Set it before running this command, or pass --skip-wallet to register without binding.",
);
process.exit(1);
}
}

const client = buildClient({ ipfs: false });

console.log(`Registering agent "${opts.name}"...`);
const result = await client.registerAgent(
opts.name,
opts.description,
opts.genre,
opts.model,
);

console.log("Agent registered!");
console.log(` Agent ID: ${result.agentId}`);
console.log(` TX: ${result.txHash}`);

if (opts.wallet && !opts.skipWallet && walletKey) {
const walletAddress = opts.wallet as Address;
console.log(`Setting agent wallet to ${walletAddress}...`);
const walletResult = await client.setAgentWallet(
result.agentId,
walletAddress,
walletKey,
);
console.log(`Agent wallet set! TX: ${walletResult.txHash}`);
}
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
},
);
}
28 changes: 28 additions & 0 deletions packages/cli/src/commands/chain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { readFileSync } from "node:fs";
import type { Command } from "commander";
import { buildClient } from "../sdk.js";

export function registerChain(program: Command): void {
program
.command("chain")
.description("Chain a new plot onto an existing storyline")
.requiredOption("-s, --storyline <id>", "Storyline ID")
.requiredOption("-f, --file <path>", "Path to content file (plain text)")
.action(async (opts: { storyline: string; file: string }) => {
try {
const content = readFileSync(opts.file, "utf-8");
const storylineId = BigInt(opts.storyline);
const client = buildClient({ ipfs: true });

console.log(`Chaining plot onto storyline ${storylineId}...`);
const result = await client.chainPlot(storylineId, content);

console.log("Plot chained!");
console.log(` TX: ${result.txHash}`);
console.log(` CID: ${result.contentCid}`);
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
});
}
33 changes: 33 additions & 0 deletions packages/cli/src/commands/claim.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { Command } from "commander";
import type { Address } from "viem";
import { buildClient } from "../sdk.js";

export function registerClaim(program: Command): void {
program
.command("claim")
.description("Claim accumulated royalties for a storyline token")
.requiredOption("-a, --address <tokenAddress>", "Storyline ERC-20 token address")
.action(async (opts: { address: string }) => {
try {
const tokenAddress = opts.address as Address;
const client = buildClient({ ipfs: false });

console.log("Checking royalties...");
const info = await client.getRoyaltyInfo(tokenAddress);
console.log(` Unclaimed: ${info.unclaimed}`);
console.log(` Beneficiary: ${info.beneficiary}`);

if (info.unclaimed === 0n) {
console.log("No royalties to claim.");
return;
}

console.log("Claiming royalties...");
const txHash = await client.claimRoyalties(tokenAddress);
console.log(`Royalties claimed! TX: ${txHash}`);
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
});
}
35 changes: 35 additions & 0 deletions packages/cli/src/commands/create.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { readFileSync } from "node:fs";
import type { Command } from "commander";
import { buildClient } from "../sdk.js";

export function registerCreate(program: Command): void {
program
.command("create")
.description("Create a new storyline from a content file")
.requiredOption("-t, --title <title>", "Storyline title")
.requiredOption("-f, --file <path>", "Path to content file (plain text)")
.requiredOption("-g, --genre <genre>", "Genre label")
.option("-d, --deadline", "Enable sunset deadline", false)
.action(async (opts: { title: string; file: string; genre: string; deadline: boolean }) => {
try {
const content = readFileSync(opts.file, "utf-8");
const client = buildClient({ ipfs: true });

console.log(`Creating storyline "${opts.title}"...`);
const result = await client.createStoryline(
opts.title,
content,
opts.genre,
opts.deadline,
);

console.log("Storyline created!");
console.log(` ID: ${result.storylineId}`);
console.log(` TX: ${result.txHash}`);
console.log(` CID: ${result.contentCid}`);
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
});
}
133 changes: 133 additions & 0 deletions packages/cli/src/commands/status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import type { Command } from "commander";
import { createClient } from "@supabase/supabase-js";
import { type Address, formatUnits } from "viem";

Check warning on line 3 in packages/cli/src/commands/status.ts

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

'Address' is defined but never used
import { buildClient } from "../sdk.js";
import { loadConfig } from "../config.js";

export function registerStatus(program: Command): void {
program
.command("status")
.description("Query storyline data (plots, deadline, token price) from Supabase and on-chain")
.requiredOption("-s, --storyline <id>", "Storyline ID")
.action(async (opts: { storyline: string }) => {
try {
const storylineId = BigInt(opts.storyline);
const cfg = loadConfig();
const client = buildClient({ ipfs: false });

console.log(`Fetching storyline ${storylineId}...`);

// -----------------------------------------------------------------
// 1. On-chain event data (always available)
// -----------------------------------------------------------------
const info = await client.getStoryline(storylineId);
if (!info) {
console.error(`Storyline ${storylineId} not found on-chain.`);
process.exit(1);
}

// -----------------------------------------------------------------
// 2. Supabase metadata (optional — richer data when configured)
// -----------------------------------------------------------------
let dbRow: {
plot_count: number;
last_plot_time: string | null;
has_deadline: boolean;
sunset: boolean;
writer_type: number | null;
block_timestamp: string | null;
} | null = null;

if (cfg.supabaseUrl && cfg.supabaseAnonKey) {
const supabase = createClient(cfg.supabaseUrl, cfg.supabaseAnonKey);
const { data } = await supabase
.from("storylines")
.select("plot_count, last_plot_time, has_deadline, sunset, writer_type, block_timestamp")
.eq("storyline_id", Number(storylineId))
.single();
dbRow = data;
}

// -----------------------------------------------------------------
// 3. On-chain token price (MCV2_Bond)
// -----------------------------------------------------------------
const tokenPrice = await client.getTokenPrice(info.tokenAddress);

// -----------------------------------------------------------------
// 4. On-chain royalty info
// -----------------------------------------------------------------
let unclaimedRoyalty: bigint | null = null;
try {
const royalty = await client.getRoyaltyInfo(info.tokenAddress);
unclaimedRoyalty = royalty.unclaimed;
} catch {
// Token may not have a bond yet
}

// -----------------------------------------------------------------
// 5. Fall back to event-derived plot count if no Supabase
// -----------------------------------------------------------------
let plotCount: number;
if (dbRow) {
plotCount = dbRow.plot_count;
} else {
const plots = await client.getPlots(storylineId);
plotCount = plots.length;
}

// -----------------------------------------------------------------
// Display
// -----------------------------------------------------------------
console.log();
console.log(`Title: ${info.title}`);
console.log(`Creator: ${info.creator}`);
console.log(`Token: ${info.tokenAddress}`);
console.log(`Has deadline: ${info.hasDeadline ? "yes" : "no"}`);
console.log(`Opening CID: ${info.openingCID}`);
console.log(`Plot count: ${plotCount}`);

if (dbRow) {
console.log(`Sunset: ${dbRow.sunset ? "yes" : "no"}`);
console.log(`Writer type: ${dbRow.writer_type === 1 ? "agent" : dbRow.writer_type === 0 ? "human" : "unknown"}`);
if (dbRow.block_timestamp) {
console.log(`Created: ${new Date(dbRow.block_timestamp).toISOString()}`);
}
if (dbRow.last_plot_time) {
console.log(`Last plot: ${new Date(dbRow.last_plot_time).toISOString()}`);
}

// Deadline remaining (72h from last plot)
if (dbRow.has_deadline && dbRow.last_plot_time && !dbRow.sunset) {
const DEADLINE_HOURS = 72;
const deadlineMs =
new Date(dbRow.last_plot_time).getTime() + DEADLINE_HOURS * 60 * 60 * 1000;
const remainingMs = deadlineMs - Date.now();
if (remainingMs <= 0) {
console.log(`Deadline: expired`);
} else {
const totalMin = Math.floor(remainingMs / 60_000);
const days = Math.floor(totalMin / 1440);
const hours = Math.floor((totalMin % 1440) / 60);
const mins = totalMin % 60;
const parts: string[] = [];
if (days > 0) parts.push(`${days}d`);
if (hours > 0) parts.push(`${hours}h`);
if (mins > 0 || parts.length === 0) parts.push(`${mins}m`);
console.log(`Deadline: ${parts.join(" ")} remaining`);
}
}
}

if (tokenPrice) {
console.log(`Token price: ${tokenPrice.priceFormatted} ETH`);
}

if (unclaimedRoyalty !== null && unclaimedRoyalty > 0n) {
console.log(`Unclaimed royalty: ${formatUnits(unclaimedRoyalty, 18)} ETH`);
}
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
});
}
Loading
Loading