feat(cli)!: subject-based command tree (tea-style) + global --output flag

BREAKING:
- Flat oo verb-noun layout replaced with subject → verb, mirroring the
  gitea `tea` CLI. Every subject is one file in cmd/oo/:

    oo calendar       list | events | add | delete
    oo projects       list | get | milestones | create | update | delete
    oo tasks          list | get | create | update | delete | subtask add
    oo users          list | self                  (alias: oo whoami)
    oo contacts       list | get | delete | info-add
    oo persons        list | create | delete
    oo companies      list | create | delete
    oo opportunities  list | get | create | delete | stages | member-add
    oo cases          list | create | delete | member-add
    oo crm-tasks      list | create | delete | categories
    oo applications   sync

- Global `--output/-o table|json` flag on the root command; every list uses
  a single printTable helper that flattens nested bidCurrency → abbreviation
  for readability, truncates long cells, and dumps verbatim in JSON mode.

Library additions surfaced in the CLI:
- oo users / oo whoami via SelfUserID + GetUsers.
- oo projects full CRUD via Create/Update/Delete/GetProjectByID/Milestones.
- oo crm-tasks and oo cases via ListCRMTasks/CreateCRMTask/DeleteCRMTask/
  ListTaskCategories/ListCases/CreateCase/DeleteCase/AddCaseMember.
- oo contacts info-add via AddContactInfo.
- oo opportunities stages/member-add via ListDealStages/AddOpportunityMember.

Internal:
- cmd/oo/common.go — rootCmd, newOO (uses AuthenticateContext), printTable,
  printObject, fmtCell, deref* helpers.
- cmd/oo/crm.go deleted; content split into contacts.go, opportunities.go,
  cases.go, crm_tasks.go for locality.

Docs:
- README: new subject→verb table + migration note.
- AGENTS.md: CLI section rewritten; new rule enforcing subject→verb and
  printTable/printObject routing.
- CHANGELOG: 0.5.0 entry with full old→new mapping.

Made-with: Cursor
This commit is contained in:
2026-04-24 14:55:43 +01:00
parent cff8145f8c
commit 1e6d22f38d
15 changed files with 1406 additions and 313 deletions
+126 -22
View File
@@ -5,40 +5,36 @@ import (
"fmt"
"os"
"strconv"
"strings"
"text/tabwriter"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/joho/godotenv"
"github.com/spf13/cobra"
)
// outputFormat is the value of the global --output/-o flag. Valid values:
// "table" (default, tabwriter-rendered), "json" (machine-readable).
var outputFormat = "table"
var rootCmd = &cobra.Command{
Use: "oo",
Short: "OnlyOffice Workspace CLI (Go port of cv/bin/office)",
Use: "oo",
Short: "OnlyOffice Workspace CLI — subject-based command tree",
Long: "oo is a thin CLI over github.com/eslider/go-onlyoffice.\nCommands are grouped by OnlyOffice subject (calendar, projects, tasks, users, persons, companies, opportunities, cases, crm-tasks, applications).",
SilenceUsage: true,
SilenceErrors: false,
}
func init() {
rootCmd.PersistentFlags().StringVarP(&outputFormat, "output", "o", "table", "output format: table|json")
}
// execute runs the root command. Exported only to main.go in the same package.
func execute() error { return rootCmd.Execute() }
func init() {
rootCmd.AddCommand(cmdCalList())
rootCmd.AddCommand(cmdCalEvents())
rootCmd.AddCommand(cmdCalAdd())
rootCmd.AddCommand(cmdCalDel())
rootCmd.AddCommand(cmdTaskList())
rootCmd.AddCommand(cmdTaskAdd())
rootCmd.AddCommand(cmdSubtaskAdd())
rootCmd.AddCommand(cmdTaskUpdate())
rootCmd.AddCommand(cmdCRMContacts())
rootCmd.AddCommand(cmdCRMAddContact())
rootCmd.AddCommand(cmdCRMDeals())
rootCmd.AddCommand(cmdCRMAddDeal())
rootCmd.AddCommand(cmdCRMCases())
rootCmd.AddCommand(cmdAppsSync())
}
// newOO loads env (incl. .env in CWD) and returns an authenticated client.
// godotenv is a CLI-only concern; the library itself never loads dotfiles.
func newOO() (*onlyoffice.Client, error) {
func newOO(cmd *cobra.Command) (*onlyoffice.Client, error) {
_ = godotenv.Load()
creds := onlyoffice.GetEnvironmentCredentials()
if creds.Url == "" || creds.User == "" || creds.Password == "" {
@@ -46,20 +42,110 @@ func newOO() (*onlyoffice.Client, error) {
}
c := onlyoffice.NewClient(creds)
c.SetDefaults(onlyoffice.GetEnvironmentDefaults())
if err := c.Authenticate(); err != nil {
if err := c.AuthenticateContext(cmd.Context()); err != nil {
return nil, err
}
return c, nil
}
// printJSON dumps any value as indented JSON.
func printJSON(v any) {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(v)
}
// printObject renders a single value. JSON output dumps verbatim; table output
// prints key/value pairs when v is a map, otherwise falls back to JSON.
func printObject(v any) {
if outputFormat == "json" {
printJSON(v)
return
}
m, ok := v.(map[string]any)
if !ok {
printJSON(v)
return
}
w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
for _, k := range sortedKeys(m) {
fmt.Fprintf(w, "%s\t%s\n", k, fmtCell(m[k]))
}
_ = w.Flush()
}
// printTable renders a list of rows. headers select & order the columns.
// When outputFormat=="json" the raw slice is dumped as-is.
func printTable(headers []string, rows []map[string]any) {
if outputFormat == "json" {
printJSON(rows)
return
}
if len(rows) == 0 {
fmt.Println("(empty)")
return
}
w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
fmt.Fprintln(w, strings.Join(headers, "\t"))
for _, row := range rows {
cells := make([]string, len(headers))
for i, h := range headers {
cells[i] = fmtCell(row[h])
}
fmt.Fprintln(w, strings.Join(cells, "\t"))
}
_ = w.Flush()
}
// fmtCell turns an arbitrary value into a compact table cell.
func fmtCell(v any) string {
switch x := v.(type) {
case nil:
return ""
case string:
return truncate(x, 80)
case bool:
if x {
return "true"
}
return "false"
case float64:
if x == float64(int64(x)) {
return strconv.FormatInt(int64(x), 10)
}
return strconv.FormatFloat(x, 'f', -1, 64)
case int, int32, int64:
return fmt.Sprintf("%d", x)
default:
b, _ := json.Marshal(x)
return truncate(string(b), 80)
}
}
func truncate(s string, n int) string {
s = strings.ReplaceAll(s, "\n", " ")
if len(s) > n {
return s[:n-1] + "…"
}
return s
}
func sortedKeys(m map[string]any) []string {
ks := make([]string, 0, len(m))
for k := range m {
ks = append(ks, k)
}
// Deterministic ordering; small map so O(n log n) is fine.
for i := 1; i < len(ks); i++ {
for j := i; j > 0 && ks[j-1] > ks[j]; j-- {
ks[j-1], ks[j] = ks[j], ks[j-1]
}
}
return ks
}
// flexIDFloat coerces OnlyOffice numeric id fields surfaced as float64 / int
// / string into a float64. CLI-only; the library's own flexInt is unexported.
// / string into a float64.
func flexIDFloat(v any) float64 {
switch x := v.(type) {
case float64:
@@ -71,3 +157,21 @@ func flexIDFloat(v any) float64 {
return f
}
}
// idString returns the id field as a string (works for int/float/string).
func idString(m map[string]any, key string) string {
v, ok := m[key]
if !ok {
return ""
}
switch x := v.(type) {
case string:
return x
case float64:
return strconv.FormatInt(int64(x), 10)
case int:
return strconv.Itoa(x)
default:
return fmt.Sprint(x)
}
}