From 69d03baccaed86c6c73915f962a46f29fecdb10a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 19:13:21 +0000 Subject: [PATCH] feat(cli): add nak-style git (NIP-34) + podcast (NIP-F4) commands git (repository metadata + collaboration events; clone/push packfile transport is out of scope): - git announce publish a kind:30617 repository announcement - git list list a user's repo announcements - git show print one announcement (naddr or kind:pubkey:id), cache-first - git issue publish a kind:1621 issue against a repo (fetches the repo announcement to build the EventHintBundle) podcast (NIP-F4): - podcast metadata publish kind:10154 show metadata (replaceable) - podcast publish publish a kind:54 episode (--audio URL[,URL]) - podcast list list a user's metadata + episodes Thin assembly over quartz GitRepositoryEvent / GitIssueEvent / PodcastMetadataEvent / PodcastEpisodeEvent. Verified offline: announce-> show cache round-trip (name/clone/hashtags), issue kind:1621 against the repo, podcast metadata kind:10154 + episode kind:54. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY --- cli/README.md | 19 ++ .../com/vitorpamplona/amethyst/cli/Main.kt | 22 ++ .../amethyst/cli/commands/GitCommands.kt | 248 ++++++++++++++++++ .../amethyst/cli/commands/PodcastCommands.kt | 178 +++++++++++++ 4 files changed, 467 insertions(+) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PodcastCommands.kt diff --git a/cli/README.md b/cli/README.md index 47cacd7383..416f23dbd9 100644 --- a/cli/README.md +++ b/cli/README.md @@ -245,6 +245,25 @@ Filter flags are shared by `fetch` and `subscribe`: `--kind K[,K]`, `--author U[ | `amy gift wrap --to USER [EVENT-JSON] [--relay …]` | NIP-59: seal a signed inner event for USER and wrap it in a kind:1059 gift wrap. Prints the wrap; `--relay` also broadcasts it. | | `amy gift unwrap [GIFTWRAP-JSON]` | Decrypt + unseal a kind:1059 wrap addressed to the active account; prints the inner event. | +### Git (NIP-34) + +nak's `clone`/`push`/`pull` (git-packfile transport over relays/GRASP) are out of scope — these are the metadata + collaboration events. + +| Command | What it does | +|---|---| +| `amy git announce --name N [--description D] [--clone URL[,URL]] [--web URL[,URL]] [--relay URL[,URL]] [--maintainer HEX[,HEX]] [--hashtag T[,T]] [--earliest-commit C] [--d ID]` | Publish a kind:30617 repository announcement. | +| `amy git list [USER]` | List a user's repo announcements (defaults to self). | +| `amy git show NADDR\|kind:pubkey:id` | Print one repo announcement (cache-first). | +| `amy git issue NADDR\|coords --subject S [BODY] [--hashtag T[,T]]` | Publish a kind:1621 issue against a repo. BODY from arg or stdin. | + +### Podcasts (NIP-F4) + +| Command | What it does | +|---|---| +| `amy podcast metadata --title T --image URL --description D [--website URL[,URL]]` | Publish kind:10154 show metadata (replaceable). | +| `amy podcast publish --title T --description D --audio URL[,URL] [--audio-type MIME] [--image URL] [--content MD]` | Publish a kind:54 episode. | +| `amy podcast list [USER] [--limit N]` | List a user's show metadata + episodes. | + ### Blossom blobs (NIP-B7) | Command | What it does | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 903d023c5b..22ffa2f68a 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.cli.commands.FetchCommand import com.vitorpamplona.amethyst.cli.commands.FilterCommand import com.vitorpamplona.amethyst.cli.commands.FollowCommand import com.vitorpamplona.amethyst.cli.commands.GiftCommands +import com.vitorpamplona.amethyst.cli.commands.GitCommands import com.vitorpamplona.amethyst.cli.commands.GroupCommands import com.vitorpamplona.amethyst.cli.commands.InitCommands import com.vitorpamplona.amethyst.cli.commands.KeyCommands @@ -47,6 +48,7 @@ import com.vitorpamplona.amethyst.cli.commands.NotesCommands import com.vitorpamplona.amethyst.cli.commands.NsiteCommands import com.vitorpamplona.amethyst.cli.commands.OfferCommands import com.vitorpamplona.amethyst.cli.commands.OutboxCommand +import com.vitorpamplona.amethyst.cli.commands.PodcastCommands import com.vitorpamplona.amethyst.cli.commands.ProfileCommands import com.vitorpamplona.amethyst.cli.commands.PublishCommand import com.vitorpamplona.amethyst.cli.commands.RelayCommands @@ -209,6 +211,8 @@ private suspend fun dispatch(argv: Array): Int { "outbox" -> OutboxCommand.run(dataDir, tail) "blossom" -> BlossomCommands.dispatch(dataDir, tail) "sync" -> SyncCommand.run(dataDir, tail) + "git" -> GitCommands.dispatch(dataDir, tail) + "podcast" -> PodcastCommands.dispatch(dataDir, tail) else -> { System.err.println("unknown subcommand: $head") printUsage() @@ -414,6 +418,24 @@ private fun printUsage() { | blossom list --server URL [USER] list a user's blobs (defaults to self) | blossom delete HASH --server URL delete a blob you own | + |Git (NIP-34): + | git announce --name N [--description D] publish a kind:30617 repo announcement + | [--clone URL[,URL]] [--web URL[,URL]] (--d sets the identifier; defaults to name) + | [--relay URL[,URL]] [--maintainer HEX[,]] + | [--hashtag T[,T]] [--earliest-commit C] [--d ID] + | git list [USER] list a user's repo announcements (default self) + | git show NADDR|kind:pubkey:id print one repo announcement + | git issue NADDR|coords --subject S [BODY] publish a kind:1621 issue against a repo + | [--hashtag T[,T]] [--relay URL[,URL]] (BODY from arg or stdin) + | + |Podcasts (NIP-F4): + | podcast metadata --title T --image URL publish kind:10154 show metadata + | --description D [--website URL[,URL]] + | podcast publish --title T --description D publish a kind:54 episode + | --audio URL[,URL] [--audio-type MIME] + | [--image URL] [--content MARKDOWN] + | podcast list [USER] [--limit N] list a user's metadata + episodes + | |Static websites (NIP-5A kind:15128/35128): | nsite fetch AUTHOR [--d ID] [--path P] resolve one path over Nostr + Blossom and | [--server URL[,URL]] [--relay URL[,URL]] VERIFY it against the manifest's sha256 pin diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt new file mode 100644 index 0000000000..9b14eeac03 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt @@ -0,0 +1,248 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress +import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent + +/** + * `amy git ` — NIP-34 Nostr-native git + * repositories (nak's `git`, adapted to amy's event-publish model). + * + * announce publish a kind:30617 repository announcement + * list list a user's repository announcements + * show print one repository announcement (naddr or coordinates) + * issue publish a kind:1621 issue against a repository + * + * nak's clone/push/pull (git-packfile transport over relays/GRASP) are out + * of scope — they need a real git plumbing layer. These are the metadata / + * collaboration events. Thin assembly only: every event lives in quartz + * (`GitRepositoryEvent`, `GitIssueEvent`). + */ +object GitCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int = + route( + "git", + tail, + "git ", + mapOf( + "announce" to { rest -> announce(dataDir, rest) }, + "list" to { rest -> list(dataDir, rest) }, + "show" to { rest -> show(dataDir, rest) }, + "issue" to { rest -> issue(dataDir, rest) }, + ), + ) + + private suspend fun announce( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val name = args.flag("name") ?: return Output.error("bad_args", "git announce requires --name") + val csv = { key: String -> + args + .flag(key) + ?.split(',') + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + .orEmpty() + } + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val template = + GitRepositoryEvent.build( + name = name, + description = args.flag("description"), + webUrls = csv("web"), + cloneUrls = csv("clone"), + relays = csv("relay"), + maintainers = csv("maintainer"), + hashtags = csv("hashtag"), + earliestUniqueCommit = args.flag("earliest-commit"), + personalFork = args.bool("personal-fork"), + dTag = args.flag("d") ?: name, + ) + val signed = ctx.signer.sign(template) + val targets = RawEventSupport.publishTargets(ctx, args) + val ack = ctx.publish(signed, targets) + Output.emit( + mapOf( + "event_id" to signed.id, + "address" to Address.assemble(signed.kind, signed.pubKey, args.flag("d") ?: name), + "published_to" to ack.filterValues { it }.keys.map { it.url }, + ), + ) + return 0 + } + } + + private suspend fun list( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + Context.open(dataDir).use { ctx -> + ctx.prepare() + val author = args.positionalOrNull(0)?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex + val relays = RawEventSupport.queryTargets(ctx, args) + val received = ctx.drain(relays.associateWith { listOf(Filter(kinds = listOf(GitRepositoryEvent.KIND), authors = listOf(author))) }) + val repos = + received + .asSequence() + .map { it.second } + .filterIsInstance() + .distinctBy { it.dTag() } + .sortedByDescending { it.createdAt } + .map { repoSummary(it) } + .toList() + Output.emit(mapOf("pubkey" to author, "count" to repos.size, "repositories" to repos)) + return 0 + } + } + + private suspend fun show( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val coord = args.positional(0, "naddr-or-coordinates") + val addr = resolveAddress(coord) ?: return Output.error("bad_args", "expected an naddr or kind:pubkey:identifier (or pubkey:identifier)") + if (addr.kind != GitRepositoryEvent.KIND) { + return Output.error("bad_args", "not a git repository address (expected kind ${GitRepositoryEvent.KIND}, got ${addr.kind})") + } + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val repo = fetchRepo(ctx, addr, args) ?: return Output.error("not_found", "no repository announcement found for $coord") + Output.emit(repoSummary(repo) + mapOf("event_id" to repo.id, "content" to repo.content)) + return 0 + } + } + + private suspend fun issue( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val coord = args.positional(0, "repo-naddr-or-coordinates") + val subject = args.flag("subject") ?: return Output.error("bad_args", "git issue requires --subject") + val addr = resolveAddress(coord) ?: return Output.error("bad_args", "expected an naddr or kind:pubkey:identifier") + val body = args.positionalOrNull(1) ?: "" + val topics = + args + .flag("hashtag") + ?.split(',') + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + .orEmpty() + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val repo = fetchRepo(ctx, addr, args) ?: return Output.error("not_found", "no repository announcement found for $coord") + val template = + GitIssueEvent.build( + subject = subject, + content = body, + repository = EventHintBundle(repo), + notify = emptyList(), + topics = topics, + ) + val signed = ctx.signer.sign(template) + // Deliver to the repo's advertised relays when present, else our targets. + val repoRelays = repo.relays().mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet() + val targets = RawEventSupport.relayFlag(args).ifEmpty { repoRelays }.ifEmpty { ctx.outboxRelays() } + val ack = ctx.publish(signed, targets) + Output.emit( + mapOf( + "event_id" to signed.id, + "kind" to signed.kind, + "repository" to Address.assemble(addr.kind, addr.pubKeyHex, addr.dTag), + "subject" to subject, + "published_to" to ack.filterValues { it }.keys.map { it.url }, + ), + ) + return 0 + } + } + + // ------------------------------------------------------------------ + + private suspend fun fetchRepo( + ctx: Context, + addr: Address, + args: Args, + ): GitRepositoryEvent? { + // Cache-first, then drain the query relays. + val filter = + Filter( + kinds = listOf(GitRepositoryEvent.KIND), + authors = listOf(addr.pubKeyHex), + tags = mapOf("d" to listOf(addr.dTag)), + limit = 1, + ) + ctx.store + .query(filter) + .firstOrNull() + ?.let { return it as? GitRepositoryEvent } + val relays = RawEventSupport.queryTargets(ctx, args) + ctx.drain(relays.associateWith { listOf(filter) }) + return ctx.store.query(filter).firstOrNull() as? GitRepositoryEvent + } + + /** Accept `naddr1…`, `kind:pubkey:dtag`, or `pubkey:dtag` (kind defaults to 30617). */ + private fun resolveAddress(input: String): Address? { + val trimmed = input.trim().removePrefix("nostr:") + if (trimmed.startsWith("naddr")) { + val n = NAddress.parse(trimmed) ?: return null + return Address(n.kind, n.author, n.dTag) + } + Address.parse(trimmed)?.let { return it } + val parts = trimmed.split(":") + return if (parts.size == 2 && parts[0].length == 64) Address(GitRepositoryEvent.KIND, parts[0], parts[1]) else null + } + + private fun repoSummary(repo: GitRepositoryEvent): Map = + mapOf( + "identifier" to repo.dTag(), + "name" to repo.name(), + "description" to repo.description(), + "clone" to repo.clones(), + "web" to repo.webs(), + "relays" to repo.relays(), + "maintainers" to repo.maintainers(), + "hashtags" to repo.hashtags(), + "address" to Address.assemble(repo.kind, repo.pubKey, repo.dTag()), + ) +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PodcastCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PodcastCommands.kt new file mode 100644 index 0000000000..098b83194d --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PodcastCommands.kt @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.AudioTag +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent + +/** + * `amy podcast ` — NIP-F4 podcasts (nak's `podcast`). + * + * metadata publish a kind:10154 show metadata (replaceable) + * publish publish a kind:54 episode + * list list a user's podcast metadata + episodes + * + * Thin assembly only: events live in quartz (`PodcastMetadataEvent`, + * `PodcastEpisodeEvent`). + */ +object PodcastCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int = + route( + "podcast", + tail, + "podcast ", + mapOf( + "metadata" to { rest -> metadata(dataDir, rest) }, + "publish" to { rest -> publish(dataDir, rest) }, + "list" to { rest -> list(dataDir, rest) }, + ), + ) + + private suspend fun metadata( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val title = args.flag("title") ?: return Output.error("bad_args", "podcast metadata requires --title") + val image = args.flag("image") ?: return Output.error("bad_args", "podcast metadata requires --image") + val description = args.flag("description") ?: return Output.error("bad_args", "podcast metadata requires --description") + val websites = + args + .flag("website") + ?.split(',') + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + .orEmpty() + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val signed = ctx.signer.sign(PodcastMetadataEvent.build(title, image, description, websites)) + val ack = ctx.publish(signed, RawEventSupport.publishTargets(ctx, args)) + Output.emit( + mapOf( + "event_id" to signed.id, + "kind" to signed.kind, + "title" to title, + "published_to" to ack.filterValues { it }.keys.map { it.url }, + ), + ) + return 0 + } + } + + private suspend fun publish( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val title = args.flag("title") ?: return Output.error("bad_args", "podcast publish requires --title") + val description = args.flag("description") ?: return Output.error("bad_args", "podcast publish requires --description") + val audioType = args.flag("audio-type") + val audios = + args + .flag("audio") + ?.split(',') + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + ?.map { AudioTag(it, audioType) } + .orEmpty() + if (audios.isEmpty()) return Output.error("bad_args", "podcast publish requires --audio URL[,URL…]") + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val template = + PodcastEpisodeEvent.build( + title = title, + description = description, + audios = audios, + markdownContent = args.flag("content", "") ?: "", + image = args.flag("image"), + ) + val signed = ctx.signer.sign(template) + val ack = ctx.publish(signed, RawEventSupport.publishTargets(ctx, args)) + Output.emit( + mapOf( + "event_id" to signed.id, + "kind" to signed.kind, + "title" to title, + "audios" to audios.map { it.url }, + "published_to" to ack.filterValues { it }.keys.map { it.url }, + ), + ) + return 0 + } + } + + private suspend fun list( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val limit = args.intFlag("limit", 50) + Context.open(dataDir).use { ctx -> + ctx.prepare() + val author = args.positionalOrNull(0)?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex + val relays = RawEventSupport.queryTargets(ctx, args) + val received = + ctx.drain( + relays.associateWith { + listOf(Filter(kinds = listOf(PodcastMetadataEvent.KIND, PodcastEpisodeEvent.KIND), authors = listOf(author), limit = limit)) + }, + ) + val events = received.map { it.second }.distinctBy { it.id } + val show = events.filterIsInstance().maxByOrNull { it.createdAt } + val episodes = + events + .filterIsInstance() + .sortedByDescending { it.createdAt } + .map { + mapOf( + "event_id" to it.id, + "title" to it.title(), + "description" to it.description(), + "audios" to it.audios().map { a -> a.url }, + "created_at" to it.createdAt, + ) + } + Output.emit( + mapOf( + "pubkey" to author, + "metadata" to + show?.let { + mapOf("title" to it.title(), "description" to it.description(), "image" to it.image(), "websites" to it.websites()) + }, + "episode_count" to episodes.size, + "episodes" to episodes, + ), + ) + return 0 + } + } +}