Files
llmgw/internal/cli/org.go
T
Ilker ManapandClaude Opus 5.5 3104f4b706 Manage people and the tenancy tree, from the web and the CLI
Nothing in the tree could be renamed, moved or removed, and an approved
person stayed in the project they were first put in — from the web or
the CLI. Now:

- /admin/users is the tree, each project opening onto its people and
  keys, with approve, move, suspend, reinstate and remove for people,
  revoke for keys, and add, rename, move and remove at every level,
  plus a search over every account. Forms post and get the page back,
  so it works with no script; htmx 2.0.11 (0BSD, embedded, checked
  against npm's integrity hash) swaps only the changed part, keeping
  open projects open.
- llmgw org tree/add/rename/move/remove, users move/delete, and
  keys list --all.
- Projects, teams and organizations are named by id, path
  (Acme/platform/gateway) or unique name; users by id or email. With a
  single project --project can be left out, instead of defaulting to
  "default", which a tree built from the web does not have.

Moving a person moves the keys they made; operator keys stay with the
project. Only empty things are removed, and never a project that had a
key, since usage history refers to it.

Also: keys init no longer reparents an existing team or project;
reinstating an account with no project is refused (a rejected signup
used to come back active and fail at its first key); rejecting a
signup removes it; keys limits --budget takes whole units like the
quotas page.

Checked in headless Chrome: a move from an open project updates the
page in place, keeps the project open, and the search still filters.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:26:39 +02:00

335 lines
9.2 KiB
Go

package cli
import (
"context"
"errors"
"fmt"
"io"
"github.com/spf13/cobra"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
func newOrgCmd() *cobra.Command {
var sf storeFlags
cmd := &cobra.Command{
Use: "org",
Short: "Organizations, teams and projects: see, add, rename, move, remove",
Long: "The tenancy tree an account lives in. A person is in a project, a project\n" +
"in a team, a team in an organization, and limits can be set on any of them.\n\n" +
"Anything that takes one of them accepts its id, its path\n" +
"(\"Acme/platform/gateway\") or its name when that name is unique.",
}
sf.bind(cmd)
open := func(cmd *cobra.Command) (*store.DB, error) { return sf.open(cmd) }
cmd.AddCommand(newOrgTreeCmd(open), newOrgAddCmd(open), newOrgRenameCmd(open),
newOrgMoveCmd(open), newOrgRemoveCmd(open))
return cmd
}
type dbOpener func(*cobra.Command) (*store.DB, error)
func newOrgTreeCmd(open dbOpener) *cobra.Command {
var people bool
cmd := &cobra.Command{
Use: "tree",
Short: "Show the whole tree, with how many accounts and keys each project has",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
s, err := open(cmd)
if err != nil {
return err
}
defer s.Close()
tree, err := s.Tree(cmd.Context())
if err != nil {
return err
}
var byProject map[string][]store.User
if people {
users, err := s.Users(cmd.Context(), "")
if err != nil {
return err
}
byProject = map[string][]store.User{}
for _, u := range users {
byProject[u.ProjectID] = append(byProject[u.ProjectID], u)
}
}
printTree(cmd.OutOrStdout(), tree, byProject)
if people && len(byProject[""]) > 0 {
fmt.Fprintf(cmd.OutOrStdout(), "\nwaiting for approval\n")
for _, u := range byProject[""] {
fmt.Fprintf(cmd.OutOrStdout(), " %s %s %s\n", u.Email, u.State, u.ID)
}
}
return nil
},
}
cmd.Flags().BoolVar(&people, "people", false, "list the accounts in each project")
return cmd
}
func printTree(w io.Writer, tree []store.TreeOrg, people map[string][]store.User) {
if len(tree) == 0 {
fmt.Fprintln(w, "no organizations yet; `llmgw org add org <name>` starts one")
return
}
for _, o := range tree {
fmt.Fprintf(w, "%s (%s)\n", o.Name, o.ID)
if len(o.Teams) == 0 {
fmt.Fprintln(w, " no teams")
}
for _, t := range o.Teams {
fmt.Fprintf(w, " %s (%s)\n", t.Name, t.ID)
if len(t.Projects) == 0 {
fmt.Fprintln(w, " no projects")
}
for _, p := range t.Projects {
fmt.Fprintf(w, " %s (%s) %s, %s\n", p.Name, p.ID,
countOf(p.Users, "account"), keysOf(p))
for _, u := range people[p.ID] {
admin := ""
if u.IsAdmin {
admin = " admin"
}
fmt.Fprintf(w, " %s %s%s\n", u.Email, u.State, admin)
}
}
}
}
}
func keysOf(p store.TreeProject) string {
s := countOf(p.ActiveKeys, "key")
if revoked := p.Keys - p.ActiveKeys; revoked > 0 {
s += fmt.Sprintf(" (+%d revoked)", revoked)
}
return s
}
func countOf(n int, noun string) string {
if n == 1 {
return "1 " + noun
}
return fmt.Sprintf("%d %ss", n, noun)
}
func levelArg(arg string) (string, error) {
switch arg {
case "org", "organization":
return "org", nil
case "team", "project":
return arg, nil
}
return "", fmt.Errorf("%q is not org, team or project", arg)
}
func newOrgAddCmd(open dbOpener) *cobra.Command {
var in string
cmd := &cobra.Command{
Use: "add <org|team|project> <name>",
Short: "Add an organization, a team to one, or a project to a team",
Example: " llmgw org add org Acme\n" +
" llmgw org add team platform --in Acme\n" +
" llmgw org add project gateway --in Acme/platform",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
level, err := levelArg(args[0])
if err != nil {
return err
}
s, err := open(cmd)
if err != nil {
return err
}
defer s.Close()
ctx := cmd.Context()
var id, where string
switch level {
case "org":
id, err = s.CreateOrg(ctx, args[1])
case "team":
var o store.Org
if o, err = resolveOrg(ctx, s, in); err == nil {
id, err = s.CreateTeam(ctx, o.ID, args[1])
where = " in " + o.Name
}
case "project":
var t store.TeamPath
if t, err = resolveTeam(ctx, s, in); err == nil {
id, err = s.CreateProject(ctx, t.ID, args[1])
where = " in " + t.OrgName + " / " + t.Name
}
}
if errors.Is(err, store.ErrDuplicateName) {
return fmt.Errorf("there is already a %s named %q there", level, args[1])
}
if err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "%s %s%s (%s)\n", level, args[1], where, id)
return nil
},
}
cmd.Flags().StringVar(&in, "in", "", "the organization a team goes in, or the team a project goes in")
return cmd
}
func newOrgRenameCmd(open dbOpener) *cobra.Command {
return &cobra.Command{
Use: "rename <org|team|project> <which> <new name>",
Short: "Rename an organization, team or project; ids and everything under it stay",
Example: " llmgw org rename project Acme/platform/gateway api-gateway",
Args: cobra.ExactArgs(3),
RunE: func(cmd *cobra.Command, args []string) error {
level, err := levelArg(args[0])
if err != nil {
return err
}
s, err := open(cmd)
if err != nil {
return err
}
defer s.Close()
id, err := resolveLevel(cmd.Context(), s, level, args[1])
if err != nil {
return err
}
switch level {
case "org":
err = s.RenameOrg(cmd.Context(), id, args[2])
case "team":
err = s.RenameTeam(cmd.Context(), id, args[2])
case "project":
err = s.RenameProject(cmd.Context(), id, args[2])
}
if errors.Is(err, store.ErrDuplicateName) {
return fmt.Errorf("there is already a %s named %q beside it", level, args[2])
}
if err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "%s %s is now %q\n", level, id, args[2])
return nil
},
}
}
func newOrgMoveCmd(open dbOpener) *cobra.Command {
var to string
cmd := &cobra.Command{
Use: "move <team|project> <which> --to <parent>",
Short: "Put a team in another organization, or a project in another team",
Long: "Everything under it moves too: projects, accounts and keys. From then on it\n" +
"inherits the new parent's limits. Usage already recorded is not rewritten.",
Example: " llmgw org move project Acme/platform/gateway --to Scania/ml\n" +
" llmgw org move team platform --to Scania",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
s, err := open(cmd)
if err != nil {
return err
}
defer s.Close()
ctx := cmd.Context()
switch args[0] {
case "team":
t, err := resolveTeam(ctx, s, args[1])
if err != nil {
return err
}
o, err := resolveOrg(ctx, s, to)
if err != nil {
return err
}
if err := s.MoveTeam(ctx, t.ID, o.ID); err != nil {
return dupAs(err, "team", t.Name)
}
fmt.Fprintf(cmd.OutOrStdout(), "team %s is now in %s\n", t.Name, o.Name)
case "project":
p, err := resolveProject(ctx, s, args[1])
if err != nil {
return err
}
t, err := resolveTeam(ctx, s, to)
if err != nil {
return err
}
if err := s.MoveProject(ctx, p.ID, t.ID); err != nil {
return dupAs(err, "project", p.Path)
}
fmt.Fprintf(cmd.OutOrStdout(), "%s is now in %s / %s\n", p.Path, t.OrgName, t.Name)
default:
return fmt.Errorf("only a team or a project moves; to move an account, `llmgw users move`")
}
return nil
},
}
cmd.Flags().StringVar(&to, "to", "", "the organization or team it goes to")
_ = cmd.MarkFlagRequired("to")
return cmd
}
func dupAs(err error, level, name string) error {
if errors.Is(err, store.ErrDuplicateName) {
return fmt.Errorf("the destination already has a %s named like %s", level, name)
}
return err
}
func newOrgRemoveCmd(open dbOpener) *cobra.Command {
return &cobra.Command{
Use: "remove <org|team|project> <which>",
Short: "Remove an organization, team or project that nothing is in",
Long: "Only something empty is removed: an organization with no teams, a team with\n" +
"no projects, a project with no accounts and no keys. A project that ever had\n" +
"a key keeps it, revoked, because usage history refers to it; rename such a\n" +
"project instead. Its limits go with it.",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
level, err := levelArg(args[0])
if err != nil {
return err
}
s, err := open(cmd)
if err != nil {
return err
}
defer s.Close()
id, err := resolveLevel(cmd.Context(), s, level, args[1])
if err != nil {
return err
}
switch level {
case "org":
err = s.DeleteOrg(cmd.Context(), id)
case "team":
err = s.DeleteTeam(cmd.Context(), id)
case "project":
err = s.DeleteProject(cmd.Context(), id)
}
if err != nil {
return fmt.Errorf("%s %s was not removed: %w", level, args[1], err)
}
fmt.Fprintf(cmd.OutOrStdout(), "%s %s removed\n", level, id)
return nil
},
}
}
func resolveLevel(ctx context.Context, s *store.DB, level, arg string) (string, error) {
switch level {
case "org":
o, err := resolveOrg(ctx, s, arg)
return o.ID, err
case "team":
t, err := resolveTeam(ctx, s, arg)
return t.ID, err
default:
p, err := resolveProject(ctx, s, arg)
return p.ID, err
}
}