diff --git a/crates/sprout-cli/src/commands/mod.rs b/crates/sprout-cli/src/commands/mod.rs
index 38e91ee89f..0e61afd4af 100644
--- a/crates/sprout-cli/src/commands/mod.rs
+++ b/crates/sprout-cli/src/commands/mod.rs
@@ -4,6 +4,7 @@ pub mod feed;
 pub mod messages;
 pub mod pack;
 pub mod reactions;
+pub mod repos;
 pub mod social;
 pub mod users;
 pub mod workflows;
diff --git a/crates/sprout-cli/src/commands/repos.rs b/crates/sprout-cli/src/commands/repos.rs
new file mode 100644
index 0000000000..779d503a5d
--- /dev/null
+++ b/crates/sprout-cli/src/commands/repos.rs
@@ -0,0 +1,97 @@
+use crate::client::SproutClient;
+use crate::error::CliError;
+use crate::validate::validate_repo_id;
+
+// ---------------------------------------------------------------------------
+// Create repo — publish kind:30617
+// ---------------------------------------------------------------------------
+
+pub async fn cmd_create_repo(
+    client: &SproutClient,
+    repo_id: &str,
+    name: Option<&str>,
+    description: Option<&str>,
+    clone_urls: &[String],
+    web_url: Option<&str>,
+    relays: &[String],
+) -> Result<(), CliError> {
+    validate_repo_id(repo_id)?;
+
+    let clone_refs: Vec<&str> = clone_urls.iter().map(|s| s.as_str()).collect();
+    let relay_refs: Vec<&str> = relays.iter().map(|s| s.as_str()).collect();
+
+    let builder = sprout_sdk::build_repo_announcement(
+        repo_id,
+        name,
+        description,
+        &clone_refs,
+        web_url,
+        &relay_refs,
+    )
+    .map_err(|e| CliError::Other(format!("build_repo_announcement failed: {e}")))?;
+
+    let event = client.sign_event(builder)?;
+    let resp = client.submit_event(event).await?;
+    println!("{resp}");
+    Ok(())
+}
+
+// ---------------------------------------------------------------------------
+// Get repo — query kind:30617 by owner + d-tag
+// ---------------------------------------------------------------------------
+
+pub async fn cmd_get_repo(
+    client: &SproutClient,
+    repo_id: &str,
+    owner: Option<&str>,
+) -> Result<(), CliError> {
+    validate_repo_id(repo_id)?;
+
+    let mut filter = serde_json::json!({
+        "kinds": [30617],
+        "#d": [repo_id]
+    });
+
+    // If owner specified, filter by author pubkey; otherwise return any match.
+    // Note: without --owner, multiple repos with the same name (different owners) may be returned.
+    if let Some(pk) = owner {
+        crate::validate::validate_hex64(pk)?;
+        filter["authors"] = serde_json::json!([pk]);
+    }
+
+    let resp = client.query(&filter).await?;
+    println!("{resp}");
+    Ok(())
+}
+
+// ---------------------------------------------------------------------------
+// List repos — query kind:30617 by author
+// ---------------------------------------------------------------------------
+
+pub async fn cmd_list_repos(
+    client: &SproutClient,
+    owner: Option<&str>,
+    limit: Option<u32>,
+) -> Result<(), CliError> {
+    // Default to self if no owner specified.
+    let pubkey = match owner {
+        Some(pk) => {
+            crate::validate::validate_hex64(pk)?;
+            pk.to_string()
+        }
+        None => client.keys().public_key().to_hex(),
+    };
+
+    let mut filter = serde_json::json!({
+        "kinds": [30617],
+        "authors": [pubkey]
+    });
+
+    if let Some(n) = limit {
+        filter["limit"] = serde_json::json!(n);
+    }
+
+    let resp = client.query(&filter).await?;
+    println!("{resp}");
+    Ok(())
+}
diff --git a/crates/sprout-cli/src/lib.rs b/crates/sprout-cli/src/lib.rs
index e435d8913d..6368a2e347 100644
--- a/crates/sprout-cli/src/lib.rs
+++ b/crates/sprout-cli/src/lib.rs
@@ -474,6 +474,50 @@ enum Cmd {
         pubkey: String,
     },
 
+    // ---- Git Repos ---------------------------------------------------------
+    /// Create a git repository (publishes kind:30617 announcement)
+    #[command(name = "create-repo")]
+    CreateRepo {
+        /// Repository identifier: [a-zA-Z0-9._-]{1,64}
+        #[arg(long)]
+        id: String,
+        /// Human-readable display name
+        #[arg(long)]
+        name: Option<String>,
+        /// Repository description
+        #[arg(long)]
+        description: Option<String>,
+        /// Clone URL(s) — can be specified multiple times
+        #[arg(long = "clone")]
+        clone_urls: Vec<String>,
+        /// Web browsing URL
+        #[arg(long)]
+        web: Option<String>,
+        /// Preferred relay(s) — can be specified multiple times
+        #[arg(long = "relay")]
+        relays: Vec<String>,
+    },
+    /// Get a repository's announcement event by ID
+    #[command(name = "get-repo")]
+    GetRepo {
+        /// Repository identifier (d-tag)
+        #[arg(long)]
+        id: String,
+        /// Owner pubkey (64-char hex). Omit to match any owner.
+        #[arg(long)]
+        owner: Option<String>,
+    },
+    /// List repositories (defaults to your own)
+    #[command(name = "list-repos")]
+    ListRepos {
+        /// Owner pubkey (64-char hex). Omit for your repos.
+        #[arg(long)]
+        owner: Option<String>,
+        /// Maximum number of results
+        #[arg(long)]
+        limit: Option<u32>,
+    },
+
     // ---- Pack (local) ------------------------------------------------------
     /// Persona pack operations (local, no relay connection needed)
     #[command(subcommand)]
@@ -818,6 +862,33 @@ async fn run(cli: Cli) -> Result<(), CliError> {
             commands::social::cmd_get_contact_list(&client, &pubkey).await
         }
 
+        // ---- Git Repos -------------------------------------------------
+        Cmd::CreateRepo {
+            id,
+            name,
+            description,
+            clone_urls,
+            web,
+            relays,
+        } => {
+            commands::repos::cmd_create_repo(
+                &client,
+                &id,
+                name.as_deref(),
+                description.as_deref(),
+                &clone_urls,
+                web.as_deref(),
+                &relays,
+            )
+            .await
+        }
+        Cmd::GetRepo { id, owner } => {
+            commands::repos::cmd_get_repo(&client, &id, owner.as_deref()).await
+        }
+        Cmd::ListRepos { owner, limit } => {
+            commands::repos::cmd_list_repos(&client, owner.as_deref(), limit).await
+        }
+
         // ---- Pack (local) --------------------------------------------------
         Cmd::Pack(_) => unreachable!("handled above"),
     }
@@ -879,6 +950,7 @@ mod tests {
             "approve-step",
             "archive-channel",
             "create-channel",
+            "create-repo",
             "create-workflow",
             "delete-channel",
             "delete-message",
@@ -892,6 +964,7 @@ mod tests {
             "get-messages",
             "get-presence",
             "get-reactions",
+            "get-repo",
             "get-thread",
             "get-user-notes",
             "get-users",
@@ -902,6 +975,7 @@ mod tests {
             "list-channel-members",
             "list-channels",
             "list-dms",
+            "list-repos",
             "list-workflows",
             "open-dm",
             "pack",
diff --git a/crates/sprout-cli/src/validate.rs b/crates/sprout-cli/src/validate.rs
index 9deff826f6..719297abec 100644
--- a/crates/sprout-cli/src/validate.rs
+++ b/crates/sprout-cli/src/validate.rs
@@ -22,6 +22,31 @@ pub fn validate_hex64(s: &str) -> Result<(), CliError> {
     Ok(())
 }
 
+/// Validate a git repo identifier: `[a-zA-Z0-9._-]{1,64}`, no leading dots, no `..`.
+pub fn validate_repo_id(s: &str) -> Result<(), CliError> {
+    if s.is_empty() || s.len() > 64 {
+        return Err(CliError::Usage(format!(
+            "repo ID must be 1-64 characters (got {})",
+            s.len()
+        )));
+    }
+    if s.starts_with('.') {
+        return Err(CliError::Usage("repo ID must not start with '.'".into()));
+    }
+    if s.contains("..") {
+        return Err(CliError::Usage("repo ID must not contain '..'".into()));
+    }
+    if !s
+        .chars()
+        .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
+    {
+        return Err(CliError::Usage(format!(
+            "repo ID contains invalid characters (allowed: a-z A-Z 0-9 . _ -): {s}"
+        )));
+    }
+    Ok(())
+}
+
 /// Validate content does not exceed MAX_CONTENT_BYTES (65,536).
 pub fn validate_content_size(content: &str) -> Result<(), CliError> {
     if content.len() > MAX_CONTENT_BYTES {
@@ -491,4 +516,47 @@ mod tests {
         merge_mentions(&mut m, &["extra".into()], 50);
         assert_eq!(m.len(), 50);
     }
+
+    // ── validate_repo_id ─────────────────────────────────────────────────────
+
+    #[test]
+    fn validate_repo_id_valid() {
+        assert!(super::validate_repo_id("my-repo").is_ok());
+        assert!(super::validate_repo_id("repo_v2.0").is_ok());
+        assert!(super::validate_repo_id("a").is_ok());
+    }
+
+    #[test]
+    fn validate_repo_id_boundary_64_chars() {
+        let id = "a".repeat(64);
+        assert!(super::validate_repo_id(&id).is_ok());
+    }
+
+    #[test]
+    fn validate_repo_id_rejects_empty() {
+        assert!(super::validate_repo_id("").is_err());
+    }
+
+    #[test]
+    fn validate_repo_id_rejects_over_64() {
+        let id = "a".repeat(65);
+        assert!(super::validate_repo_id(&id).is_err());
+    }
+
+    #[test]
+    fn validate_repo_id_rejects_leading_dot() {
+        assert!(super::validate_repo_id(".hidden").is_err());
+    }
+
+    #[test]
+    fn validate_repo_id_rejects_double_dot() {
+        assert!(super::validate_repo_id("foo..bar").is_err());
+    }
+
+    #[test]
+    fn validate_repo_id_rejects_invalid_chars() {
+        assert!(super::validate_repo_id("my repo").is_err());
+        assert!(super::validate_repo_id("foo/bar").is_err());
+        assert!(super::validate_repo_id("a@b").is_err());
+    }
 }
diff --git a/crates/sprout-sdk/src/builders.rs b/crates/sprout-sdk/src/builders.rs
index 25307d24fb..523f5096ec 100644
--- a/crates/sprout-sdk/src/builders.rs
+++ b/crates/sprout-sdk/src/builders.rs
@@ -5,7 +5,7 @@
 
 use nostr::{EventBuilder, Kind, Tag};
 use sprout_core::{
-    kind::KIND_AGENT_OBSERVER_FRAME,
+    kind::{KIND_AGENT_OBSERVER_FRAME, KIND_GIT_REPO_ANNOUNCEMENT},
     observer::{
         content_looks_like_nip44, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG,
         OBSERVER_FRAME_TELEMETRY,
@@ -744,6 +744,158 @@ pub fn extract_channel_id(event: &nostr::Event) -> Option<Uuid> {
     })
 }
 
+// ── Builder 30: build_repo_announcement ──────────────────────────────────────
+
+/// Build a git repository announcement event (kind:30617, NIP-34).
+///
+/// Creates or updates a repository. The `repo_id` is the unique identifier
+/// (d-tag) — must be `[a-zA-Z0-9._-]{1,64}`, no leading dots, no `..`.
+///
+/// This is a parameterized replaceable event: publishing again with the same
+/// `repo_id` updates the announcement (relay overwrites the previous one).
+pub fn build_repo_announcement(
+    repo_id: &str,
+    name: Option<&str>,
+    description: Option<&str>,
+    clone_urls: &[&str],
+    web_url: Option<&str>,
+    relays: &[&str],
+) -> Result<EventBuilder, SdkError> {
+    // Validate repo_id
+    if repo_id.is_empty() {
+        return Err(SdkError::InvalidInput("repo_id must not be empty".into()));
+    }
+    if repo_id.len() > 64 {
+        return Err(SdkError::InvalidInput(format!(
+            "repo_id exceeds 64 characters (got {})",
+            repo_id.len()
+        )));
+    }
+    if !repo_id
+        .chars()
+        .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
+    {
+        return Err(SdkError::InvalidInput(
+            "repo_id may only contain [a-zA-Z0-9._-]".into(),
+        ));
+    }
+    if repo_id.starts_with('.') {
+        return Err(SdkError::InvalidInput(
+            "repo_id must not start with a dot".into(),
+        ));
+    }
+    if repo_id.contains("..") {
+        return Err(SdkError::InvalidInput(
+            "repo_id must not contain '..'".into(),
+        ));
+    }
+
+    // Validate optional name
+    if let Some(n) = name {
+        if n.len() > 128 {
+            return Err(SdkError::InvalidInput(format!(
+                "name exceeds 128 characters (got {})",
+                n.len()
+            )));
+        }
+    }
+
+    // Validate optional description
+    if let Some(d) = description {
+        if d.len() > 1024 {
+            return Err(SdkError::InvalidInput(format!(
+                "description exceeds 1024 characters (got {})",
+                d.len()
+            )));
+        }
+    }
+
+    // Validate clone_urls
+    if clone_urls.len() > 5 {
+        return Err(SdkError::InvalidInput(format!(
+            "too many clone_urls (max 5, got {})",
+            clone_urls.len()
+        )));
+    }
+    for url in clone_urls {
+        if url.is_empty() {
+            return Err(SdkError::InvalidInput("clone_url must not be empty".into()));
+        }
+        if url.len() > 512 {
+            return Err(SdkError::InvalidInput(format!(
+                "clone_url exceeds 512 characters (got {})",
+                url.len()
+            )));
+        }
+    }
+
+    // Validate web_url
+    if let Some(url) = web_url {
+        if !url.starts_with("http://") && !url.starts_with("https://") {
+            return Err(SdkError::InvalidInput(format!(
+                "web_url must start with http:// or https:// (got {:?})",
+                url
+            )));
+        }
+        if url.len() > 512 {
+            return Err(SdkError::InvalidInput(format!(
+                "web_url exceeds 512 characters (got {})",
+                url.len()
+            )));
+        }
+    }
+
+    // Validate relays
+    if relays.len() > 10 {
+        return Err(SdkError::InvalidInput(format!(
+            "too many relays (max 10, got {})",
+            relays.len()
+        )));
+    }
+    for relay in relays {
+        if !relay.starts_with("ws://") && !relay.starts_with("wss://") {
+            return Err(SdkError::InvalidInput(format!(
+                "relay must start with ws:// or wss:// (got {:?})",
+                relay
+            )));
+        }
+        if relay.len() > 256 {
+            return Err(SdkError::InvalidInput(format!(
+                "relay exceeds 256 characters (got {})",
+                relay.len()
+            )));
+        }
+    }
+
+    // Build tags
+    let mut tags = vec![tag(&["d", repo_id])?];
+    if let Some(n) = name {
+        tags.push(tag(&["name", n])?);
+    }
+    if let Some(d) = description {
+        tags.push(tag(&["description", d])?);
+    }
+    if !clone_urls.is_empty() {
+        let mut clone_tag = vec!["clone"];
+        clone_tag.extend_from_slice(clone_urls);
+        tags.push(tag(&clone_tag)?);
+    }
+    if let Some(url) = web_url {
+        tags.push(tag(&["web", url])?);
+    }
+    if !relays.is_empty() {
+        let mut relay_tag = vec!["relays"];
+        relay_tag.extend_from_slice(relays);
+        tags.push(tag(&relay_tag)?);
+    }
+
+    Ok(EventBuilder::new(
+        Kind::Custom(KIND_GIT_REPO_ANNOUNCEMENT as u16),
+        "",
+        tags,
+    ))
+}
+
 // ── Tests ────────────────────────────────────────────────────────────────────
 
 #[cfg(test)]
@@ -1697,4 +1849,119 @@ mod tests {
         assert!(has_tag(&ev, "h", &parent.to_string()));
         assert!(!has_tag(&ev, "h", &ephemeral.to_string()));
     }
+
+    // ── build_repo_announcement ───────────────────────────────────────────────
+
+    #[test]
+    fn repo_announcement_happy_path_all_fields() {
+        let ev = sign(
+            build_repo_announcement(
+                "my-repo",
+                Some("My Repo"),
+                Some("A test repository"),
+                &["https://github.com/example/my-repo.git"],
+                Some("https://github.com/example/my-repo"),
+                &["wss://relay.example.com"],
+            )
+            .unwrap(),
+        );
+        assert_eq!(ev.kind.as_u16(), 30617);
+        assert_eq!(ev.content, "");
+        assert!(has_tag(&ev, "d", "my-repo"));
+        assert!(has_tag(&ev, "name", "My Repo"));
+        assert!(has_tag(&ev, "description", "A test repository"));
+        assert!(has_tag(
+            &ev,
+            "clone",
+            "https://github.com/example/my-repo.git"
+        ));
+        assert!(has_tag(&ev, "web", "https://github.com/example/my-repo"));
+        // relays is a multi-value tag — check the tag key exists
+        assert!(ev.tags.iter().any(|t| {
+            let s = t.as_slice();
+            s.first().map(|v| v.as_str()) == Some("relays")
+                && s.get(1).map(|v| v.as_str()) == Some("wss://relay.example.com")
+        }));
+    }
+
+    #[test]
+    fn repo_announcement_happy_path_minimal() {
+        let ev = sign(build_repo_announcement("bare-repo", None, None, &[], None, &[]).unwrap());
+        assert_eq!(ev.kind.as_u16(), 30617);
+        assert_eq!(ev.content, "");
+        assert!(has_tag(&ev, "d", "bare-repo"));
+        // No optional tags present
+        assert!(!ev
+            .tags
+            .iter()
+            .any(|t| t.as_slice().first().map(|v| v.as_str()) == Some("name")));
+        assert!(!ev
+            .tags
+            .iter()
+            .any(|t| t.as_slice().first().map(|v| v.as_str()) == Some("clone")));
+    }
+
+    #[test]
+    fn repo_announcement_rejects_empty_repo_id() {
+        let err = build_repo_announcement("", None, None, &[], None, &[]).unwrap_err();
+        assert!(matches!(err, SdkError::InvalidInput(_)));
+    }
+
+    #[test]
+    fn repo_announcement_rejects_leading_dot() {
+        let err = build_repo_announcement(".hidden", None, None, &[], None, &[]).unwrap_err();
+        assert!(matches!(err, SdkError::InvalidInput(_)));
+    }
+
+    #[test]
+    fn repo_announcement_rejects_double_dot() {
+        let err = build_repo_announcement("some..repo", None, None, &[], None, &[]).unwrap_err();
+        assert!(matches!(err, SdkError::InvalidInput(_)));
+    }
+
+    #[test]
+    fn repo_announcement_rejects_repo_id_over_64_chars() {
+        let long_id = "a".repeat(65);
+        let err = build_repo_announcement(&long_id, None, None, &[], None, &[]).unwrap_err();
+        assert!(matches!(err, SdkError::InvalidInput(_)));
+    }
+
+    #[test]
+    fn repo_announcement_rejects_invalid_chars_in_repo_id() {
+        let err = build_repo_announcement("bad repo!", None, None, &[], None, &[]).unwrap_err();
+        assert!(matches!(err, SdkError::InvalidInput(_)));
+    }
+
+    #[test]
+    fn repo_announcement_multiple_clone_urls_multi_value_tag() {
+        let ev = sign(
+            build_repo_announcement(
+                "multi-clone",
+                None,
+                None,
+                &[
+                    "https://relay.example.com/git/abc/multi-clone",
+                    "ssh://git@github.com/org/multi-clone.git",
+                ],
+                None,
+                &[],
+            )
+            .unwrap(),
+        );
+        // clone is a multi-value tag per NIP-34: ["clone", url1, url2, ...]
+        let clone_tag = ev
+            .tags
+            .iter()
+            .find(|t| t.as_slice().first().map(|v| v.as_str()) == Some("clone"))
+            .expect("clone tag missing");
+        let vals: Vec<&str> = clone_tag
+            .as_slice()
+            .iter()
+            .skip(1)
+            .map(|v| v.as_str())
+            .collect();
+        assert_eq!(vals.len(), 2);
+        assert_eq!(vals[0], "https://relay.example.com/git/abc/multi-clone");
+        assert_eq!(vals[1], "ssh://git@github.com/org/multi-clone.git");
+    }
 }
