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:
+126
-22
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user