Skip to content

Commit bca2d59

Browse files
committed
cli: add 'kai org list' and 'kai org delete' commands
New top-level subcommand 'kai org' for remote org management. Pairs with the new server-side DELETE /api/v1/orgs/{slug} endpoint that owner-only deletes an org and every repo inside it. Safety model for 'kai org delete <slug>': - reads the repo list first so the user sees the full blast radius - prompts the user to type the slug exactly to confirm - --yes / -y skips the prompt for scripts - server requires ?confirm=<slug> independently, so a bug in the CLI can't trivially delete the wrong org 'kai org list' shows orgs the current user belongs to (slug + name).
1 parent f58b7a6 commit bca2d59

2 files changed

Lines changed: 151 additions & 0 deletions

File tree

kai-cli/cmd/kai/main.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1473,6 +1473,37 @@ var remoteDelCmd = &cobra.Command{
14731473
RunE: runRemoteDel,
14741474
}
14751475

1476+
// ── Org management ─────────────────────────────────────────────────
1477+
1478+
var orgCmd = &cobra.Command{
1479+
Use: "org",
1480+
Short: "Manage organizations on the remote server",
1481+
Long: `Inspect and administer organizations on a remote Kailab server
1482+
(kaicontext.com by default). Requires an authenticated session
1483+
(run 'kai auth login' first).`,
1484+
}
1485+
1486+
var orgListCmd = &cobra.Command{
1487+
Use: "list",
1488+
Short: "List organizations you belong to",
1489+
RunE: runOrgList,
1490+
}
1491+
1492+
var orgDeleteYes bool
1493+
1494+
var orgDeleteCmd = &cobra.Command{
1495+
Use: "delete <slug>",
1496+
Short: "Delete an organization (hard delete, destructive)",
1497+
Long: `Hard-deletes an organization, every repo inside it, and all
1498+
dependent data (snapshots, refs, CI runs, webhooks, secrets, variables,
1499+
billing, memberships). Irreversible.
1500+
1501+
Only the org owner can delete an org. The CLI requires you to type the
1502+
slug to confirm; --yes skips the prompt (for scripts).`,
1503+
Args: cobra.ExactArgs(1),
1504+
RunE: runOrgDelete,
1505+
}
1506+
14761507
var pushCmd = &cobra.Command{
14771508
Use: "push [remote] [target...]",
14781509
Short: "Push snapshots, changesets, and reviews to a remote server",
@@ -3361,6 +3392,10 @@ func init() {
33613392
remoteCmd.AddCommand(remoteListCmd)
33623393
remoteCmd.AddCommand(remoteDelCmd)
33633394

3395+
orgDeleteCmd.Flags().BoolVarP(&orgDeleteYes, "yes", "y", false, "Skip confirmation prompt (dangerous)")
3396+
orgCmd.AddCommand(orgListCmd)
3397+
orgCmd.AddCommand(orgDeleteCmd)
3398+
33643399
// Add ref subcommands
33653400
refCmd.AddCommand(refListCmd)
33663401
refCmd.AddCommand(refSetCmd)
@@ -3627,6 +3662,8 @@ func init() {
36273662
authCmd.GroupID = groupRemote
36283663
updateCmd.GroupID = groupRemote
36293664
rootCmd.AddCommand(remoteCmd)
3665+
orgCmd.GroupID = groupRemote
3666+
rootCmd.AddCommand(orgCmd)
36303667
rootCmd.AddCommand(pushCmd)
36313668
rootCmd.AddCommand(fetchCmd)
36323669
rootCmd.AddCommand(pullCmd)
@@ -13757,6 +13794,86 @@ func runRemoteDel(cmd *cobra.Command, args []string) error {
1375713794
return nil
1375813795
}
1375913796

13797+
// ── kai org list / delete ──────────────────────────────────────────
13798+
13799+
// newControlClientAuthed returns a ControlClient pointed at KAI_SERVER
13800+
// (default kaicontext.com) with the current user's access token wired
13801+
// in. Returns a friendly error if the user is not logged in.
13802+
func newControlClientAuthed() (*remote.ControlClient, error) {
13803+
serverURL := os.Getenv("KAI_SERVER")
13804+
if serverURL == "" {
13805+
serverURL = remote.DefaultServer
13806+
}
13807+
token, err := remote.GetValidAccessToken()
13808+
if err != nil || token == "" {
13809+
return nil, fmt.Errorf("not logged in — run 'kai auth login' first")
13810+
}
13811+
c := remote.NewControlClient(serverURL)
13812+
c.AuthToken = token
13813+
return c, nil
13814+
}
13815+
13816+
func runOrgList(cmd *cobra.Command, args []string) error {
13817+
c, err := newControlClientAuthed()
13818+
if err != nil {
13819+
return err
13820+
}
13821+
orgs, err := c.ListOrgs()
13822+
if err != nil {
13823+
return fmt.Errorf("listing orgs: %w", err)
13824+
}
13825+
if len(orgs) == 0 {
13826+
fmt.Println("You don't belong to any organizations.")
13827+
return nil
13828+
}
13829+
for _, o := range orgs {
13830+
fmt.Printf(" %-20s %s\n", o.Slug, o.Name)
13831+
}
13832+
return nil
13833+
}
13834+
13835+
func runOrgDelete(cmd *cobra.Command, args []string) error {
13836+
slug := args[0]
13837+
c, err := newControlClientAuthed()
13838+
if err != nil {
13839+
return err
13840+
}
13841+
13842+
if !orgDeleteYes {
13843+
// List repos first so the user sees the blast radius.
13844+
repos, err := c.ListRepos(slug)
13845+
if err != nil {
13846+
// A 403/404 here usually means the user isn't a member;
13847+
// we still want to try the delete for the owner-only case,
13848+
// so just warn and continue to the confirmation prompt.
13849+
fmt.Fprintf(os.Stderr, "Note: couldn't list repos (%v); proceeding.\n\n", err)
13850+
} else {
13851+
fmt.Printf("About to delete organization %q and %d repo(s):\n", slug, len(repos))
13852+
for _, r := range repos {
13853+
fmt.Printf(" - %s/%s\n", slug, r.Name)
13854+
}
13855+
fmt.Println()
13856+
fmt.Println("This is irreversible. Every snapshot, ref, CI run, webhook,")
13857+
fmt.Println("secret, variable, and membership will be permanently removed.")
13858+
fmt.Println()
13859+
}
13860+
fmt.Printf("Type the org slug (%s) to confirm: ", slug)
13861+
reader := bufio.NewReader(os.Stdin)
13862+
typed, _ := reader.ReadString('\n')
13863+
typed = strings.TrimSpace(typed)
13864+
if typed != slug {
13865+
return fmt.Errorf("confirmation did not match; org NOT deleted")
13866+
}
13867+
}
13868+
13869+
reposDeleted, err := c.DeleteOrg(slug)
13870+
if err != nil {
13871+
return fmt.Errorf("deleting org: %w", err)
13872+
}
13873+
fmt.Printf("✓ Deleted organization %q (%d repo(s))\n", slug, reposDeleted)
13874+
return nil
13875+
}
13876+
1376013877
// interactivePushOnboarding handles the case when a user tries to push without a remote configured.
1376113878
// It walks them through authentication, org selection, repo creation, and remote setup.
1376213879
func interactivePushOnboarding(remoteName string) (*remote.Client, error) {

kai-cli/internal/remote/client.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1379,6 +1379,40 @@ func (c *ControlClient) CreateOrg(slug, name string) (*OrgInfo, error) {
13791379
return &result, nil
13801380
}
13811381

1382+
// DeleteOrg hard-deletes an org, every repo in it, and all dependent
1383+
// data. Requires the caller to be the org owner. The server additionally
1384+
// requires ?confirm=<slug> — we pass it automatically.
1385+
//
1386+
// On success returns a summary: number of repos deleted.
1387+
func (c *ControlClient) DeleteOrg(slug string) (reposDeleted int, err error) {
1388+
url := fmt.Sprintf("%s/api/v1/orgs/%s?confirm=%s", c.BaseURL, slug, slug)
1389+
req, err := http.NewRequest("DELETE", url, nil)
1390+
if err != nil {
1391+
return 0, err
1392+
}
1393+
if c.AuthToken != "" {
1394+
req.Header.Set("Authorization", "Bearer "+c.AuthToken)
1395+
}
1396+
resp, err := c.HTTPClient.Do(req)
1397+
if err != nil {
1398+
return 0, fmt.Errorf("sending request: %w", err)
1399+
}
1400+
defer resp.Body.Close()
1401+
1402+
if resp.StatusCode != http.StatusOK {
1403+
body, _ := io.ReadAll(resp.Body)
1404+
return 0, fmt.Errorf("server error: %d %s", resp.StatusCode, string(body))
1405+
}
1406+
var result struct {
1407+
DeletedOrg string `json:"deleted_org"`
1408+
ReposDeleted int `json:"repos_deleted"`
1409+
}
1410+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
1411+
return 0, fmt.Errorf("decoding response: %w", err)
1412+
}
1413+
return result.ReposDeleted, nil
1414+
}
1415+
13821416
// ListRepos lists repositories in an organization.
13831417
func (c *ControlClient) ListRepos(orgSlug string) ([]RepoInfo, error) {
13841418
req, err := http.NewRequest("GET", c.BaseURL+"/api/v1/orgs/"+orgSlug+"/repos", nil)

0 commit comments

Comments
 (0)