feat(nu-cli): initial Faz 0 scaffold

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mukan Erkin TÖRÜK 2026-04-24 00:00:26 +03:00
commit 0e506c7bc0
11 changed files with 315 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
target/
*.rs.bk
.env
~/.nu/keystore/

35
CLAUDE.md Normal file
View file

@ -0,0 +1,35 @@
# nu-cli — CLAUDE.md
Geliştirici CLI. Binary adı `nu`.
## Komutlar
```bash
nu wallet new [--label default] # Yeni keypair üret, ~/.nu/keystore/ altına kaydet
nu wallet address [--label default] # Adres göster
nu wallet send --to <addr> --amount <n>
nu node info # nu_chainInfo RPC
nu node stake --amount <n>
nu node unstake --amount <n>
nu story submit --story-id <id> --parent-id <id> --ipfs-hash <cid>
nu story register --node-id <id> # Oylama için kayıt
nu story vote --node-id <id> --approve true/false
nu story show --story-id <id>
nu genesis init --chain-id <id> # genesis.toml üret
```
## Keystore
Keyler `~/.nu/keystore/<label>.key` altında saklanır.
**Faz 0:** hex plaintext (geliştirme kolaylığı)
**Faz 1:** password-encrypted (production güvenliği)
## Geliştirme
```bash
cargo run --bin nu -- wallet new
cargo run --bin nu -- node info --rpc http://localhost:8545
```

20
Cargo.toml Normal file
View file

@ -0,0 +1,20 @@
[package]
name = "nu-cli"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "nu"
path = "src/main.rs"
[dependencies]
clap = { version = "4", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
reqwest = { version = "0.12", features = ["json"] }
k256 = { version = "0.13", features = ["ecdsa"] } # secp256k1
rand = "0.8"
hex = "0.4"
dirs = "5"

25
src/commands/genesis.rs Normal file
View file

@ -0,0 +1,25 @@
use anyhow::Result;
use clap::Subcommand;
use crate::rpc::Client;
#[derive(Subcommand)]
pub enum GenesisCmd {
/// Initialize genesis block configuration
Init {
#[arg(long)] chain_id: String,
#[arg(long, default_value = "genesis.toml")] output: String,
},
}
pub async fn run(cmd: GenesisCmd, _rpc: &Client) -> Result<()> {
match cmd {
GenesisCmd::Init { chain_id, output } => {
let template = format!(
"[genesis]\nchain_id = \"{chain_id}\"\n\n[bootstrap_peers]\n# Add validator multiaddrs here\n"
);
std::fs::write(&output, template)?;
println!("Genesis config written to {output}");
}
}
Ok(())
}

4
src/commands/mod.rs Normal file
View file

@ -0,0 +1,4 @@
pub mod wallet;
pub mod node;
pub mod story;
pub mod genesis;

25
src/commands/node.rs Normal file
View file

@ -0,0 +1,25 @@
use anyhow::Result;
use clap::Subcommand;
use crate::rpc::Client;
#[derive(Subcommand)]
pub enum NodeCmd {
/// Show chain info
Info,
/// Register as validator
Stake { #[arg(long)] amount: u64 },
/// Unstake from validator set
Unstake { #[arg(long)] amount: u64 },
}
pub async fn run(cmd: NodeCmd, rpc: &Client) -> Result<()> {
match cmd {
NodeCmd::Info => {
let info = rpc.call("nu_chainInfo", serde_json::json!({})).await?;
println!("{}", serde_json::to_string_pretty(&info)?);
}
NodeCmd::Stake { amount } => println!("TODO: stake {amount} NUT"),
NodeCmd::Unstake { amount } => println!("TODO: unstake {amount} NUT"),
}
Ok(())
}

45
src/commands/story.rs Normal file
View file

@ -0,0 +1,45 @@
use anyhow::Result;
use clap::Subcommand;
use crate::rpc::Client;
#[derive(Subcommand)]
pub enum StoryCmd {
/// Submit a new story node
Submit {
#[arg(long)] story_id: String,
#[arg(long)] parent_id: String,
#[arg(long)] ipfs_hash: String,
},
/// Register to vote on a node
Register {
#[arg(long)] node_id: String,
},
/// Cast a vote
Vote {
#[arg(long)] node_id: String,
#[arg(long)] approve: bool,
},
/// Show story DAG
Show {
#[arg(long)] story_id: String,
},
}
pub async fn run(cmd: StoryCmd, rpc: &Client) -> Result<()> {
match cmd {
StoryCmd::Submit { story_id, parent_id, ipfs_hash } => {
println!("TODO: submit node story={story_id} parent={parent_id} hash={ipfs_hash}");
}
StoryCmd::Register { node_id } => {
println!("TODO: register vote for node={node_id}");
}
StoryCmd::Vote { node_id, approve } => {
println!("TODO: vote node={node_id} approve={approve}");
}
StoryCmd::Show { story_id } => {
let result = rpc.call("nu_getStory", serde_json::json!({ "story_id": story_id })).await?;
println!("{}", serde_json::to_string_pretty(&result)?);
}
}
Ok(())
}

56
src/commands/wallet.rs Normal file
View file

@ -0,0 +1,56 @@
use anyhow::Result;
use clap::Subcommand;
use k256::ecdsa::SigningKey;
use rand::rngs::OsRng;
#[derive(Subcommand)]
pub enum WalletCmd {
/// Generate a new keypair
New {
/// Label for the keystore file
#[arg(long, default_value = "default")]
label: String,
},
/// Show address for a keystore entry
Address {
#[arg(long, default_value = "default")]
label: String,
},
/// Send NUT tokens
Send {
#[arg(long)] to: String,
#[arg(long)] amount: u64,
#[arg(long, default_value = "default")] from: String,
},
}
pub async fn run(cmd: WalletCmd) -> Result<()> {
match cmd {
WalletCmd::New { label } => {
let key = SigningKey::random(&mut OsRng);
let bytes = key.to_bytes();
let hex_key = hex::encode(bytes);
let dir = crate::config::keystore_dir();
std::fs::create_dir_all(&dir)?;
let path = dir.join(format!("{label}.key"));
// Store as hex — Faz 1: add password encryption
std::fs::write(&path, &hex_key)?;
let verifying = key.verifying_key();
println!("Created keystore: {}", path.display());
println!("Address (pubkey compressed): {}", hex::encode(verifying.to_sec1_bytes()));
}
WalletCmd::Address { label } => {
let dir = crate::config::keystore_dir();
let raw = std::fs::read_to_string(dir.join(format!("{label}.key")))?;
let bytes = hex::decode(raw.trim())?;
let key = SigningKey::from_bytes(bytes.as_slice().into())?;
println!("{}", hex::encode(key.verifying_key().to_sec1_bytes()));
}
WalletCmd::Send { to, amount, from } => {
println!("TODO: nu wallet send --to {to} --amount {amount} (from: {from})");
}
}
Ok(())
}

8
src/config.rs Normal file
View file

@ -0,0 +1,8 @@
use std::path::PathBuf;
pub fn keystore_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".nu")
.join("keystore")
}

56
src/main.rs Normal file
View file

@ -0,0 +1,56 @@
mod commands;
mod config;
mod rpc;
use anyhow::Result;
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "nu", version, about = "Narrative Union CLI")]
struct Cli {
/// RPC endpoint (overrides config)
#[arg(long, env = "NU_RPC_URL", default_value = "http://localhost:8545")]
rpc: String,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Wallet management
Wallet {
#[command(subcommand)]
action: commands::wallet::WalletCmd,
},
/// Node operations
Node {
#[command(subcommand)]
action: commands::node::NodeCmd,
},
/// Story operations
Story {
#[command(subcommand)]
action: commands::story::StoryCmd,
},
/// Genesis operations (dev only)
Genesis {
#[command(subcommand)]
action: commands::genesis::GenesisCmd,
},
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
let rpc = rpc::Client::new(&cli.rpc);
match cli.command {
Command::Wallet { action } => commands::wallet::run(action).await?,
Command::Node { action } => commands::node::run(action, &rpc).await?,
Command::Story { action } => commands::story::run(action, &rpc).await?,
Command::Genesis{ action } => commands::genesis::run(action, &rpc).await?,
}
Ok(())
}

37
src/rpc.rs Normal file
View file

@ -0,0 +1,37 @@
use anyhow::Result;
use serde_json::{json, Value};
pub struct Client {
endpoint: String,
http: reqwest::Client,
}
impl Client {
pub fn new(endpoint: &str) -> Self {
Self {
endpoint: endpoint.to_string(),
http: reqwest::Client::new(),
}
}
pub async fn call(&self, method: &str, params: Value) -> Result<Value> {
let body = json!({
"jsonrpc": "2.0",
"method": method,
"params": params,
"id": 1,
});
let resp: Value = self.http
.post(&self.endpoint)
.json(&body)
.send()
.await?
.json()
.await?;
if let Some(err) = resp.get("error") {
anyhow::bail!("RPC error: {}", err);
}
Ok(resp["result"].clone())
}
}