Skip to content

Commit 798802e

Browse files
chrisdengsoclaude
andcommitted
modapi link, and init links an existing project or creates one
- `modapi link [project]` connects this directory to a project you already have: it makes a new key for the project (POST /projects/{id}/api-keys, named after this computer and directory) and writes MODAPI_SECRET_KEY and MODAPI_WEBHOOK_SECRET to the env file. Without a project it lists them to pick from. It asks before replacing a key for another project (--force in scripts) and changes nothing when the key is already for that project. - `init` no longer always creates a project. When the organization has projects it asks whether to link one or create a new one. Agents and scripts choose with --project <id or name> or --new; without either, init stops and lists the projects. - The setup skill asks the user which project to use. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 528006b commit 798802e

5 files changed

Lines changed: 604 additions & 49 deletions

File tree

‎internal/custom/commands_test.go‎

Lines changed: 151 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ type fakeAPI struct {
2525
*httptest.Server
2626
mu sync.Mutex
2727
requests []recorded
28+
// projects is the JSON array GET /projects returns (default: none).
29+
projects string
2830
}
2931

3032
type recorded struct {
@@ -50,10 +52,24 @@ func newFakeAPI(t *testing.T) *fakeAPI {
5052
return
5153
}
5254
_, _ = io.WriteString(w, `{"id":"proj_1","name":"`+body["name"].(string)+`","description":null,"domain":"acme.com","context":"A marketplace","globalFlaggingMode":"ACTIVE","enableRequestLogging":true,"casebookEnabled":true,"createdAt":"2026-10-22T00:00:00Z","updatedAt":"2026-10-22T00:00:00Z","secretKey":"sk_proj_new"}`)
55+
case r.Method == http.MethodGet && r.URL.Path == "/projects":
56+
projects := f.projects
57+
if projects == "" {
58+
projects = "[]"
59+
}
60+
_, _ = io.WriteString(w, `{"data":`+projects+`,"pagination":{"page":1,"pageSize":100,"total":0,"totalPages":1,"hasMore":false}}`)
61+
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/api-keys"):
62+
if auth != "Bearer sk_org_test" {
63+
w.WriteHeader(http.StatusUnauthorized)
64+
_, _ = io.WriteString(w, `{"message":"bad key","code":"unauthorized"}`)
65+
return
66+
}
67+
id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/projects/"), "/api-keys")
68+
_, _ = io.WriteString(w, `{"id":"key_1","name":"`+body["name"].(string)+`","projectId":"`+id+`","createdAt":"2026-10-23T00:00:00Z","secretKey":"sk_proj_linked"}`)
5369
case r.URL.Path == "/webhook-secret":
5470
_, _ = io.WriteString(w, `{"secret":"whsec_new"}`)
5571
case r.URL.Path == "/account":
56-
if auth != "Bearer sk_proj_new" && auth != "Bearer sk_proj_existing" {
72+
if auth != "Bearer sk_proj_new" && auth != "Bearer sk_proj_existing" && auth != "Bearer sk_proj_linked" {
5773
w.WriteHeader(http.StatusUnauthorized)
5874
_, _ = io.WriteString(w, `{"message":"bad key","code":"unauthorized"}`)
5975
return
@@ -151,8 +167,11 @@ func TestInitCreatesProjectAndWritesEnv(t *testing.T) {
151167
t.Fatalf(".env.local = %q, want %q", env, want)
152168
}
153169

170+
if paths := api.paths(); len(paths) < 2 || paths[0] != "GET /projects" || paths[1] != "POST /projects" {
171+
t.Fatalf("requests = %v, want GET /projects (none to link), then POST /projects", paths)
172+
}
154173
api.mu.Lock()
155-
create := api.requests[0]
174+
create := api.requests[1]
156175
api.mu.Unlock()
157176
if create.Path != "/projects" || create.Body["name"] != "market" || create.Body["brief"] != "No scams" || create.Body["usedByMinors"] != false {
158177
t.Fatalf("create request = %+v", create)
@@ -209,7 +228,7 @@ func TestInitDryRunTouchesNothing(t *testing.T) {
209228
dir := setup(t)
210229
api := newFakeAPI(t)
211230
t.Setenv("MODAPI_ORGANIZATION_KEY", "sk_org_test")
212-
stdout, _, code := run(t, "init", "--dir", dir, "--server-url", api.URL, "--dry-run", "-o", "json")
231+
stdout, _, code := run(t, "init", "--dir", dir, "--server-url", api.URL, "--new", "--dry-run", "-o", "json")
213232
if code != 0 || !strings.Contains(stdout, `"would_create_project":true`) {
214233
t.Fatalf("exit %d: %s", code, stdout)
215234
}
@@ -396,3 +415,132 @@ func TestDoctorListsProblemsWithoutAnErrorBlock(t *testing.T) {
396415
t.Fatalf("doctor printed an error block: %s", stderr)
397416
}
398417
}
418+
419+
const twoProjects = `[
420+
{"id":"proj_1","name":"Market","description":null,"domain":"acme.com","context":null,"globalFlaggingMode":"ACTIVE","enableRequestLogging":true,"casebookEnabled":true,"createdAt":"2026-10-22T00:00:00Z","updatedAt":"2026-10-22T00:00:00Z"},
421+
{"id":"proj_2","name":"Forum","description":null,"domain":null,"context":null,"globalFlaggingMode":"ACTIVE","enableRequestLogging":true,"casebookEnabled":true,"createdAt":"2026-10-22T00:00:00Z","updatedAt":"2026-10-22T00:00:00Z"}
422+
]`
423+
424+
func readEnv(t *testing.T, path string) string {
425+
t.Helper()
426+
data, err := os.ReadFile(path)
427+
if err != nil {
428+
t.Fatal(err)
429+
}
430+
return string(data)
431+
}
432+
433+
func TestInitWithProjectsAsksAnAgentToChoose(t *testing.T) {
434+
dir := setup(t)
435+
api := newFakeAPI(t)
436+
api.projects = twoProjects
437+
t.Setenv("MODAPI_ORGANIZATION_KEY", "sk_org_test")
438+
439+
_, stderr, code := run(t, "init", "--dir", dir, "--server-url", api.URL, "-o", "json")
440+
if code != 2 {
441+
t.Fatalf("exit %d, want 2: %s", code, stderr)
442+
}
443+
for _, want := range []string{"Market (proj_1)", "Forum (proj_2)", "--project", "--new"} {
444+
if !strings.Contains(stderr, want) {
445+
t.Fatalf("stderr misses %q: %s", want, stderr)
446+
}
447+
}
448+
if paths := api.paths(); len(paths) != 1 || paths[0] != "GET /projects" {
449+
t.Fatalf("requests = %v, want only the project list", paths)
450+
}
451+
}
452+
453+
func TestInitLinksAProjectByName(t *testing.T) {
454+
dir := setup(t)
455+
api := newFakeAPI(t)
456+
api.projects = twoProjects
457+
t.Setenv("MODAPI_ORGANIZATION_KEY", "sk_org_test")
458+
459+
stdout, stderr, code := run(t, "init", "--dir", dir, "--server-url", api.URL, "--project", "forum", "-o", "json")
460+
if code != 0 {
461+
t.Fatalf("exit %d\nstdout: %s\nstderr: %s", code, stdout, stderr)
462+
}
463+
if !strings.Contains(stdout, `"status":"linked"`) || strings.Contains(stdout, "sk_proj_linked") {
464+
t.Fatalf("result = %s", stdout)
465+
}
466+
if paths := api.paths(); !strings.Contains(strings.Join(paths, ","), "POST /projects/proj_2/api-keys") {
467+
t.Fatalf("requests = %v", paths)
468+
}
469+
api.mu.Lock()
470+
var name string
471+
for _, r := range api.requests {
472+
if strings.HasSuffix(r.Path, "/api-keys") {
473+
name, _ = r.Body["name"].(string)
474+
}
475+
}
476+
api.mu.Unlock()
477+
if !strings.HasPrefix(name, "CLI · ") {
478+
t.Fatalf("key name = %q", name)
479+
}
480+
if env := readEnv(t, filepath.Join(dir, ".env.local")); env != "MODAPI_SECRET_KEY=sk_proj_linked\nMODAPI_WEBHOOK_SECRET=whsec_new\n" {
481+
t.Fatalf(".env.local = %q", env)
482+
}
483+
}
484+
485+
func TestInitRefusesProjectAndNewTogether(t *testing.T) {
486+
dir := setup(t)
487+
_, _, code := run(t, "init", "--dir", dir, "--project", "x", "--new")
488+
if code != 2 {
489+
t.Fatalf("exit %d, want 2", code)
490+
}
491+
}
492+
493+
func TestLinkRefusesAmbiguousName(t *testing.T) {
494+
dir := setup(t)
495+
api := newFakeAPI(t)
496+
api.projects = strings.Replace(twoProjects, `"Forum"`, `"market"`, 1)
497+
t.Setenv("MODAPI_ORGANIZATION_KEY", "sk_org_test")
498+
499+
_, stderr, code := run(t, "link", "Market", "--dir", dir, "--server-url", api.URL)
500+
if code != 2 || !strings.Contains(stderr, "more than one project") {
501+
t.Fatalf("exit %d: %s", code, stderr)
502+
}
503+
}
504+
505+
func TestLinkAsksBeforeReplacingAnotherProjectsKey(t *testing.T) {
506+
dir := setup(t)
507+
api := newFakeAPI(t)
508+
api.projects = twoProjects
509+
t.Setenv("MODAPI_ORGANIZATION_KEY", "sk_org_test")
510+
// The account endpoint says sk_proj_existing belongs to proj_1.
511+
writeEnv(t, dir, ".env.local", "MODAPI_SECRET_KEY=sk_proj_existing\n")
512+
513+
_, stderr, code := run(t, "link", "proj_2", "--dir", dir, "--server-url", api.URL)
514+
if code != 2 || !strings.Contains(stderr, "--force") {
515+
t.Fatalf("exit %d: %s", code, stderr)
516+
}
517+
if env := readEnv(t, filepath.Join(dir, ".env.local")); !strings.Contains(env, "sk_proj_existing") {
518+
t.Fatalf("the key was replaced without --force: %q", env)
519+
}
520+
521+
stdout, stderr, code := run(t, "link", "proj_2", "--dir", dir, "--server-url", api.URL, "--force", "-o", "json")
522+
if code != 0 || !strings.Contains(stdout, `"status":"linked"`) {
523+
t.Fatalf("exit %d\nstdout: %s\nstderr: %s", code, stdout, stderr)
524+
}
525+
if env := readEnv(t, filepath.Join(dir, ".env.local")); !strings.Contains(env, "MODAPI_SECRET_KEY=sk_proj_linked") {
526+
t.Fatalf(".env.local = %q", env)
527+
}
528+
}
529+
530+
func TestLinkSameProjectChangesNothing(t *testing.T) {
531+
dir := setup(t)
532+
api := newFakeAPI(t)
533+
api.projects = twoProjects
534+
t.Setenv("MODAPI_ORGANIZATION_KEY", "sk_org_test")
535+
writeEnv(t, dir, ".env.local", "MODAPI_SECRET_KEY=sk_proj_existing\n")
536+
537+
stdout, stderr, code := run(t, "link", "Market", "--dir", dir, "--server-url", api.URL, "-o", "json")
538+
if code != 0 || !strings.Contains(stdout, `"status":"already_set_up"`) {
539+
t.Fatalf("exit %d\nstdout: %s\nstderr: %s", code, stdout, stderr)
540+
}
541+
for _, p := range api.paths() {
542+
if strings.HasSuffix(p, "/api-keys") {
543+
t.Fatalf("made a key for the project it is already linked to: %v", api.paths())
544+
}
545+
}
546+
}

‎internal/custom/common.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import (
2424

2525
// Register adds the hand-written commands to the root command.
2626
func Register(root *cobra.Command) {
27-
root.AddCommand(newLoginCmd(), newLogoutCmd(), newInitCmd(), newDoctorCmd())
27+
root.AddCommand(newLoginCmd(), newLogoutCmd(), newInitCmd(), newLinkCmd(), newDoctorCmd())
2828
preRun := root.PersistentPreRunE
2929
root.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
3030
if preRun != nil {

‎internal/custom/fallbackkey.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import (
1212
// commandsWithoutAPIKey are the top-level commands that do not call the API
1313
// with the secret key, or that find their own key.
1414
var commandsWithoutAPIKey = map[string]bool{
15-
"login": true, "logout": true, "init": true, "doctor": true,
15+
"login": true, "logout": true, "init": true, "link": true, "doctor": true,
1616
"auth": true, "configure": true, "whoami": true,
1717
"help": true, "completion": true, "version": true,
1818
}

0 commit comments

Comments
 (0)