api: list groups as JSON
An import names its group but has to learn the id it was given,
and a rerun must find the group an earlier run made, not make it again.
GET /api/groups answers with every group's id and name,
so execute looks its group up by name before it creates one.
Assisted-by: Claude Opus 5 via Claude Code
Issue: rtqvmzvp
diff --git a/AGENTS.md b/AGENTS.md
index 76cb21c..a65c3a5 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -49,6 +49,7 @@ lib/pro_tacts/
├── web/contacts.rb # The card browser and its editor
├── web/groups.rb # The group screens and their editor
├── web/setup.rb # The device setup screen and its profile
+├── web/api.rb # JSON for scripts: the group list
├── admin/ # The Phlex views those screens render, plus
│ # card_form.rb, which reads a form back into a card
├── store.rb # Sequel over SQLite: cards, change log, derived index
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index e72ff6a..f5743b6 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -133,6 +133,7 @@ module ProTacts
end
# The branches reopen Web, so they load once the plugins they call are in.
+require "pro_tacts/web/api"
require "pro_tacts/web/contacts"
require "pro_tacts/web/dav"
require "pro_tacts/web/groups"
diff --git a/lib/pro_tacts/web/api.rb b/lib/pro_tacts/web/api.rb
new file mode 100644
index 0000000..7c2dc35
--- /dev/null
+++ b/lib/pro_tacts/web/api.rb
@@ -0,0 +1,16 @@
+require "json"
+
+module ProTacts
+ class Web < Roda
+ # JSON for scripts, beside the HTML screens a person uses. An import
+ # looks its group up here by name before making it, so a rerun
+ # joins the group an earlier run made
+ # (docs/plans/2026-09-16-importing-from-macos.md).
+ hash_branch("api") do |r|
+ r.get "groups" do
+ response["Content-Type"] = "application/json"
+ store.all_groups.map { {id: it.id, name: it.name} }.to_json
+ end
+ end
+ end
+end
diff --git a/test/pro_tacts/test_api.rb b/test/pro_tacts/test_api.rb
new file mode 100644
index 0000000..5513bce
--- /dev/null
+++ b/test/pro_tacts/test_api.rb
@@ -0,0 +1,43 @@
+require_relative "../test_helper"
+
+require "json"
+require "rack/test"
+
+require "pro_tacts/web"
+
+class ApiTest < Minitest::Test
+ include Rack::Test::Methods
+ include ThrowawayContacts
+
+ def app
+ ProTacts::Web
+ end
+
+ def setup
+ header "Remote-User", "test@example.com"
+ end
+
+ def test_groups_lists_every_group_by_id_and_name
+ with_contacts({}) do |store|
+ named = store.create_group(name: "import-20260916T180412Z")
+ nameless = store.create_group
+
+ get "/api/groups"
+
+ assert_equal 200, last_response.status
+ assert_equal "application/json", last_response["Content-Type"]
+ groups = JSON.parse(last_response.body)
+ assert_equal store.all_groups.map { {"id" => it.id, "name" => it.name} }, groups
+ assert_includes groups, {"id" => named, "name" => "import-20260916T180412Z"}
+ assert_includes groups, {"id" => nameless, "name" => nil}
+ end
+ end
+
+ def test_an_unknown_api_path_is_404
+ with_contacts({}) do
+ get "/api/nope"
+
+ assert_equal 404, last_response.status
+ end
+ end
+end
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index 8ee22c4..f79fead 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -196,7 +196,7 @@ class WebTest < Minitest::Test
def test_a_refusal_covers_every_path
header "Remote-User", ""
- %w[/ /.well-known/carddav /dav/ /dav/principal/ /dav/addressbook/ /contacts /groups /setup /admin.css].each do |path|
+ %w[/ /.well-known/carddav /dav/ /dav/principal/ /dav/addressbook/ /contacts /groups /api/groups /setup /admin.css].each do |path|
get path
assert_equal 401, last_response.status, path