diff --git a/.changeset/neat-oranges-wave.md b/.changeset/neat-oranges-wave.md new file mode 100644 index 0000000..c4d1314 --- /dev/null +++ b/.changeset/neat-oranges-wave.md @@ -0,0 +1,7 @@ +--- +"linear-cli": minor +--- + +Migrate CLI parsing to Commander with strict validation, typed command handlers, Clack prompts for auth input, Ora spinner feedback, and cli-table3 table rendering. + +This removes legacy internal arg parsing (`src/args.ts`) and centralizes validation at the command definition level. \ No newline at end of file diff --git a/bun.lock b/bun.lock index d4e12d0..8a11a38 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,13 @@ "workspaces": { "": { "name": "linear-cli", + "dependencies": { + "@clack/prompts": "^1.1.0", + "cli-table3": "^0.6.5", + "commander": "14.0.3", + "ora": "^9.3.0", + "picocolors": "^1.1.1", + }, "devDependencies": { "@aliou/biome-plugins": "^0.4.1", "@biomejs/biome": "^2.0.0", @@ -73,6 +80,12 @@ "@changesets/write": ["@changesets/write@0.4.0", "", { "dependencies": { "@changesets/types": "^6.1.0", "fs-extra": "^7.0.1", "human-id": "^4.1.1", "prettier": "^2.7.1" } }, "sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q=="], + "@clack/core": ["@clack/core@1.1.0", "", { "dependencies": { "sisteransi": "^1.0.5" } }, "sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA=="], + + "@clack/prompts": ["@clack/prompts@1.1.0", "", { "dependencies": { "@clack/core": "1.1.0", "sisteransi": "^1.0.5" } }, "sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g=="], + + "@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="], + "@inquirer/external-editor": ["@inquirer/external-editor@1.0.3", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA=="], "@manypkg/find-root": ["@manypkg/find-root@1.1.0", "", { "dependencies": { "@babel/runtime": "^7.5.5", "@types/node": "^12.7.1", "find-up": "^4.1.0", "fs-extra": "^8.1.0" } }, "sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA=="], @@ -103,14 +116,26 @@ "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="], + "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], + + "cli-spinners": ["cli-spinners@3.4.0", "", {}, "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw=="], + + "cli-table3": ["cli-table3@0.6.5", "", { "dependencies": { "string-width": "^4.2.0" }, "optionalDependencies": { "@colors/colors": "1.5.0" } }, "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ=="], + + "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "detect-indent": ["detect-indent@6.1.0", "", {}, "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA=="], "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], @@ -127,6 +152,8 @@ "fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], + "get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="], + "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "globby": ["globby@11.1.0", "", { "dependencies": { "array-union": "^2.1.0", "dir-glob": "^3.0.1", "fast-glob": "^3.2.9", "ignore": "^5.2.0", "merge2": "^1.4.1", "slash": "^3.0.0" } }, "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g=="], @@ -141,12 +168,18 @@ "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + "is-interactive": ["is-interactive@2.0.0", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="], + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], "is-subdir": ["is-subdir@1.2.0", "", { "dependencies": { "better-path-resolve": "1.0.0" } }, "sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw=="], + "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], + "is-windows": ["is-windows@1.0.2", "", {}, "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], @@ -159,12 +192,20 @@ "lodash.startcase": ["lodash.startcase@4.4.0", "", {}, "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg=="], + "log-symbols": ["log-symbols@7.0.1", "", { "dependencies": { "is-unicode-supported": "^2.0.0", "yoctocolors": "^2.1.1" } }, "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg=="], + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], + "onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], + + "ora": ["ora@9.3.0", "", { "dependencies": { "chalk": "^5.6.2", "cli-cursor": "^5.0.0", "cli-spinners": "^3.2.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.1.0", "log-symbols": "^7.0.1", "stdin-discarder": "^0.3.1", "string-width": "^8.1.0" } }, "sha512-lBX72MWFduWEf7v7uWf5DHp9Jn5BI8bNPGuFgtXMmr2uDz2Gz2749y3am3agSDdkhHPHYmmxEGSKH85ZLGzgXw=="], + "outdent": ["outdent@0.5.0", "", {}, "sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q=="], "p-filter": ["p-filter@2.1.0", "", { "dependencies": { "p-map": "^2.0.0" } }, "sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw=="], @@ -201,6 +242,8 @@ "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], + "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], @@ -215,12 +258,18 @@ "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + "slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], "spawndamnit": ["spawndamnit@3.0.1", "", { "dependencies": { "cross-spawn": "^7.0.5", "signal-exit": "^4.0.1" } }, "sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg=="], "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], + "stdin-discarder": ["stdin-discarder@0.3.1", "", {}, "sha512-reExS1kSGoElkextOcPkel4NE99S0BWxjUHQeDFnR8S993JxpPX7KU4MNmO19NXhlJp+8dmdCbKQVNgLJh2teA=="], + + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], @@ -237,6 +286,8 @@ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], + "@manypkg/find-root/@types/node": ["@types/node@12.20.55", "", {}, "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ=="], "@manypkg/find-root/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], @@ -245,8 +296,14 @@ "@manypkg/get-packages/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], + "ora/string-width": ["string-width@8.2.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw=="], + "read-yaml-file/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], + "ora/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "read-yaml-file/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + + "ora/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], } } diff --git a/package.json b/package.json index f0b944c..8a054a5 100644 --- a/package.json +++ b/package.json @@ -32,5 +32,11 @@ "peerDependencies": { "typescript": "^5" }, - "dependencies": {} + "dependencies": { + "@clack/prompts": "^1.1.0", + "cli-table3": "^0.6.5", + "commander": "14.0.3", + "ora": "^9.3.0", + "picocolors": "^1.1.1" + } } diff --git a/src/args.ts b/src/args.ts deleted file mode 100644 index 39b2cda..0000000 --- a/src/args.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * Shared argument parsing utilities using Bun's parseArgs. - */ - -import { parseArgs as bunParseArgs } from "node:util"; - -export type ParsedArgs = { - values: Record; - positionals: string[]; -}; - -/** - * Parse command-line arguments using Bun's parseArgs utility. - * - * Supports: - * - Boolean flags: --flag, -f - * - String options: --key value, --key=value - * - Positional arguments - * - * All unknown flags are allowed (strict: false). - */ -export function parseArgs( - args: string[], - options?: Record< - string, - { type: "string" | "boolean"; short?: string; multiple?: boolean } - >, -): ParsedArgs { - // Default options that are common across commands - const defaultOptions = { - help: { type: "boolean" as const, short: "h" }, - }; - - const mergedOptions = { ...defaultOptions, ...options }; - - try { - const result = bunParseArgs({ - args, - options: mergedOptions, - strict: false, - allowPositionals: true, - }); - - return { - values: result.values as Record< - string, - string | boolean | string[] | undefined - >, - positionals: result.positionals, - }; - } catch { - // Fallback for edge cases - return empty result - return { - values: { help: false }, - positionals: args.filter((a) => !a.startsWith("-")), - }; - } -} - -/** - * Check if help flag is set. - */ -export function wantsHelp(parsed: ParsedArgs): boolean { - return Boolean(parsed.values.help); -} - -/** - * Get a string value from parsed args. - */ -export function getString(parsed: ParsedArgs, key: string): string | undefined { - const value = parsed.values[key]; - return typeof value === "string" ? value : undefined; -} - -/** - * Get a boolean value from parsed args. - */ -export function getBoolean(parsed: ParsedArgs, key: string): boolean { - return Boolean(parsed.values[key]); -} - -/** - * Get a number value from parsed args. - */ -export function getNumber(parsed: ParsedArgs, key: string): number | undefined { - const value = parsed.values[key]; - if (typeof value === "string") { - const num = Number.parseInt(value, 10); - return Number.isNaN(num) ? undefined : num; - } - return undefined; -} - -/** - * Get positional argument by index. - */ -export function getPositional( - parsed: ParsedArgs, - index: number, -): string | undefined { - return parsed.positionals[index]; -} diff --git a/src/auth.ts b/src/auth.ts index a56a78c..d426f94 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -3,6 +3,7 @@ * Handles token storage, validation, and auth status. */ +import * as p from "@clack/prompts"; import { fetchOrganization, fetchViewer } from "./api.ts"; import { checkConfigPermissions, @@ -301,38 +302,30 @@ export async function readTokenFromStdin(): Promise { * Prompt for token interactively. */ export async function promptForToken(): Promise { - process.stdout.write("Enter token: "); - - return new Promise((resolve) => { - let input = ""; - process.stdin.setEncoding("utf-8"); - process.stdin.on("data", (chunk) => { - input += chunk; - if (input.includes("\n")) { - process.stdin.pause(); - resolve(input.trim()); - } - }); - process.stdin.resume(); + const value = await p.password({ + message: "Enter token", + mask: "*", }); + + if (p.isCancel(value)) { + return ""; + } + + return String(value).trim(); } export async function promptForTokenKind(): Promise { - process.stdout.write("Token type [api/oauth]: "); - - return new Promise((resolve) => { - let input = ""; - process.stdin.setEncoding("utf-8"); - process.stdin.on("data", (chunk) => { - input += chunk; - if (!input.includes("\n")) { - return; - } - - process.stdin.pause(); - const value = input.trim().toLowerCase(); - resolve(value === "oauth" || value === "o" ? "oauth" : "api"); - }); - process.stdin.resume(); + const value = await p.select({ + message: "Token type", + options: [ + { value: "api", label: "API token" }, + { value: "oauth", label: "OAuth token" }, + ], }); + + if (p.isCancel(value)) { + return "api"; + } + + return value === "oauth" ? "oauth" : "api"; } diff --git a/src/cli.ts b/src/cli.ts index 28ab0ec..c82089a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,135 +1,4 @@ -import { parseArgs as bunParseArgs } from "node:util"; -import { APP_NAME, VERSION } from "./constants"; - -export interface CliOptions { - help: boolean; - version: boolean; - completion: string | null; - workspace: string | null; - command: string | null; - subcommand: string | null; - args: string[]; -} - -export function parseArgs(args: string[]): CliOptions { - const result = bunParseArgs({ - args, - options: { - help: { type: "boolean", short: "h" }, - version: { type: "boolean", short: "v" }, - completion: { type: "string" }, - workspace: { type: "string", short: "w" }, - }, - strict: false, - allowPositionals: true, - }); - - const positionals = result.positionals; - const command = positionals[0] ?? null; - const subcommand = positionals[1] ?? null; - - const subcommandArgs: string[] = []; - let foundSubcommand = false; - - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - if (!arg) continue; - - if (arg === command && !foundSubcommand) { - continue; - } - if (arg === subcommand && !foundSubcommand) { - foundSubcommand = true; - continue; - } - - if (foundSubcommand) { - if (arg === "-v" || arg === "--version") { - continue; - } - if (arg === "--completion" || arg.startsWith("--completion=")) { - continue; - } - if (arg === "-w" || arg === "--workspace") { - i++; - continue; - } - if (arg.startsWith("--workspace=")) { - continue; - } - subcommandArgs.push(arg); - } - } - - return { - help: Boolean(result.values.help), - version: Boolean(result.values.version), - completion: - typeof result.values.completion === "string" - ? result.values.completion - : null, - workspace: - typeof result.values.workspace === "string" - ? result.values.workspace - : null, - command, - subcommand, - args: subcommandArgs, - }; -} - -export function printVersion(): void { - console.log(`${APP_NAME} version ${VERSION}`); -} - -export function printHelp(): void { - const dim = (s: string) => `\x1b[2m${s}\x1b[0m`; - const bold = (s: string) => `\x1b[1m${s}\x1b[0m`; - - console.log(` -${bold("Linear CLI")} - Access Linear from your terminal via GraphQL API - -${bold("Usage:")} - ${APP_NAME} [subcommand] [flags] - -${bold("Commands:")} - auth Manage authentication ${dim("(login, logout, status, list, use)")} - issue Issue operations ${dim("(list, get, create, update, close)")} - team Team operations ${dim("(list, get)")} - project Project operations ${dim("(list, get)")} - cycle Cycle operations ${dim("(list, get)")} - comment Comment operations ${dim("(list, create, update, delete)")} - document Document operations ${dim("(list, get, create, update, delete)")} - label Label operations ${dim("(list, create, update, delete)")} - milestone Milestone operations ${dim("(list, get, create, update, delete)")} - initiative Initiative operations ${dim("(list, get)")} - user User operations ${dim("(list, get, me)")} - state Workflow state operations ${dim("(list)")} - search Search ${dim("(issues, documents, projects)")} - graphql Run arbitrary GraphQL ${dim("(query or mutation)")} - -${bold("Flags:")} - -h, --help Show help - -v, --version Show version - -w, --workspace Use a specific workspace profile - --completion Generate shell completion ${dim("(bash, zsh, fish)")} - -${bold("Examples:")} - ${APP_NAME} auth login - ${APP_NAME} issue list --team ENG --limit 10 - ${APP_NAME} issue get ENG-123 - ${APP_NAME} issue create --team ENG --title "Fix bug" - ${APP_NAME} team list - ${APP_NAME} project list - ${APP_NAME} graphql 'query { viewer { id name email } }' - -${bold("Configuration:")} - Config file: ~/.config/linear-cli/config.json - API token: Get from Linear Settings > API > Personal API keys - -Use "${APP_NAME} --help" for more information about a command. -`); -} +import { APP_NAME } from "./constants"; export function generateBashCompletion(): string { return `# ${APP_NAME} bash completion @@ -253,16 +122,16 @@ _${APP_NAME}() { state_cmds=('list:List workflow states') search_cmds=('issues:Search issues' 'documents:Search documents' 'projects:Search projects') - _arguments -s \\ - '-h[Show help]' \\ - '--help[Show help]' \\ - '-v[Show version]' \\ - '--version[Show version]' \\ - '--completion[Generate completion]:shell:(bash zsh fish)' \\ - '-w[Use a specific workspace profile]:workspace name:' \\ - '--workspace[Use a specific workspace profile]:workspace name:' \\ - '1:command:->command' \\ - '2:subcommand:->subcommand' \\ + _arguments -s \ + '-h[Show help]' \ + '--help[Show help]' \ + '-v[Show version]' \ + '--version[Show version]' \ + '--completion[Generate completion]:shell:(bash zsh fish)' \ + '-w[Use a specific workspace profile]:workspace name:' \ + '--workspace[Use a specific workspace profile]:workspace name:' \ + '1:command:->command' \ + '2:subcommand:->subcommand' \ '*::args:->args' case "$state" in diff --git a/src/commands/comment.ts b/src/commands/comment.ts index 3ad48e8..2210156 100644 --- a/src/commands/comment.ts +++ b/src/commands/comment.ts @@ -1,5 +1,4 @@ import { graphql } from "../api.ts"; -import { getPositional, getString, parseArgs, wantsHelp } from "../args.ts"; import { formatDate, printTable, @@ -8,39 +7,16 @@ import { useJson, } from "./shared.ts"; -const COMMENT_OPTIONS = { - issue: { type: "string" as const }, - body: { type: "string" as const }, - json: { type: "boolean" as const }, - limit: { type: "string" as const }, -}; - -export async function listComments(args: string[]): Promise { - const parsed = parseArgs(args, COMMENT_OPTIONS); - - if (wantsHelp(parsed)) { - console.log(` -Usage: linear comment list [options] - -List comments on an issue. - -Options: - --issue Issue identifier (required) - --json Output as JSON - -h, --help Show this help -`); - return; - } - - const issueId = getString(parsed, "issue"); - if (!issueId) { - console.error("Error: --issue is required."); - console.error("Usage: linear comment list --issue "); - process.exit(1); - } +export interface ListCommentsOptions { + issue: string; + json?: boolean; +} +export async function listComments( + options: ListCommentsOptions, +): Promise { const token = await requireToken(); - const json = await useJson(parsed); + const json = await useJson(options.json); const query = ` query IssueComments($id: String!) { @@ -70,7 +46,7 @@ Options: }>; }; }; - }>(token, query, { id: issueId }); + }>(token, query, { id: options.issue }); const comments = data.issue.comments.nodes; @@ -94,44 +70,17 @@ Options: printTable(headers, rows); } -export async function createComment(args: string[]): Promise { - const parsed = parseArgs(args, COMMENT_OPTIONS); - - if (wantsHelp(parsed)) { - console.log(` -Usage: linear comment create [options] - -Create a comment on an issue. - -Options: - --issue Issue identifier (required) - --body Comment body (required) - --json Output as JSON - -h, --help Show this help -`); - return; - } - - const issueIdentifier = getString(parsed, "issue"); - if (!issueIdentifier) { - console.error("Error: --issue is required."); - console.error( - "Usage: linear comment create --issue --body ", - ); - process.exit(1); - } - - const body = getString(parsed, "body"); - if (!body) { - console.error("Error: --body is required."); - console.error( - "Usage: linear comment create --issue --body ", - ); - process.exit(1); - } +export interface CreateCommentOptions { + issue: string; + body: string; + json?: boolean; +} +export async function createComment( + options: CreateCommentOptions, +): Promise { const token = await requireToken(); - const json = await useJson(parsed); + const json = await useJson(options.json); const issueQuery = ` query Issue($id: String!) { @@ -143,7 +92,7 @@ Options: const issueData = await graphql<{ issue: { id: string }; - }>(token, issueQuery, { id: issueIdentifier }); + }>(token, issueQuery, { id: options.issue }); const issueId = issueData.issue.id; @@ -173,7 +122,7 @@ Options: url: string; }; }; - }>(token, mutation, { input: { issueId, body } }); + }>(token, mutation, { input: { issueId, body: options.body } }); if (!data.commentCreate.success) { console.error("Error: Failed to create comment."); @@ -192,39 +141,17 @@ Options: console.log(`URL: ${comment.url}`); } -export async function updateComment(args: string[]): Promise { - const parsed = parseArgs(args, COMMENT_OPTIONS); - - if (wantsHelp(parsed)) { - console.log(` -Usage: linear comment update [options] - -Update a comment. - -Options: - --body New comment body (required) - --json Output as JSON - -h, --help Show this help -`); - return; - } - - const id = getPositional(parsed, 0); - if (!id) { - console.error("Error: Comment ID is required."); - console.error("Usage: linear comment update --body "); - process.exit(1); - } - - const body = getString(parsed, "body"); - if (!body) { - console.error("Error: --body is required."); - console.error("Usage: linear comment update --body "); - process.exit(1); - } +export interface UpdateCommentOptions { + id: string; + body: string; + json?: boolean; +} +export async function updateComment( + options: UpdateCommentOptions, +): Promise { const token = await requireToken(); - const json = await useJson(parsed); + const json = await useJson(options.json); const mutation = ` mutation CommentUpdate($id: String!, $input: CommentUpdateInput!) { @@ -248,7 +175,7 @@ Options: updatedAt: string; }; }; - }>(token, mutation, { id, input: { body } }); + }>(token, mutation, { id: options.id, input: { body: options.body } }); if (!data.commentUpdate.success) { console.error("Error: Failed to update comment."); @@ -266,31 +193,16 @@ Options: console.log(`Updated: ${formatDate(comment.updatedAt)}`); } -export async function deleteComment(args: string[]): Promise { - const parsed = parseArgs(args, COMMENT_OPTIONS); - - if (wantsHelp(parsed)) { - console.log(` -Usage: linear comment delete - -Delete a comment. - -Options: - --json Output as JSON - -h, --help Show this help -`); - return; - } - - const id = getPositional(parsed, 0); - if (!id) { - console.error("Error: Comment ID is required."); - console.error("Usage: linear comment delete "); - process.exit(1); - } +export interface DeleteCommentOptions { + id: string; + json?: boolean; +} +export async function deleteComment( + options: DeleteCommentOptions, +): Promise { const token = await requireToken(); - const json = await useJson(parsed); + const json = await useJson(options.json); const mutation = ` mutation CommentDelete($id: String!) { @@ -304,7 +216,7 @@ Options: commentDelete: { success: boolean; }; - }>(token, mutation, { id }); + }>(token, mutation, { id: options.id }); if (!data.commentDelete.success) { console.error("Error: Failed to delete comment."); @@ -312,9 +224,9 @@ Options: } if (json) { - console.log(JSON.stringify({ id, deleted: true }, null, 2)); + console.log(JSON.stringify({ id: options.id, deleted: true }, null, 2)); return; } - console.log(`Deleted comment ${id}`); + console.log(`Deleted comment ${options.id}`); } diff --git a/src/commands/cycle.ts b/src/commands/cycle.ts index cb7fc15..a6fbe60 100644 --- a/src/commands/cycle.ts +++ b/src/commands/cycle.ts @@ -1,45 +1,20 @@ import { graphql } from "../api.ts"; -import { - getNumber, - getPositional, - getString, - parseArgs, - wantsHelp, -} from "../args.ts"; import { formatDate, printTable, requireToken, useJson } from "./shared.ts"; -const CYCLE_OPTIONS = { - team: { type: "string" as const }, - limit: { type: "string" as const }, - json: { type: "boolean" as const }, -}; - function formatProgress(progress: number): string { return `${Math.round(progress * 100)}%`; } -export async function listCycles(args: string[]): Promise { - const parsed = parseArgs(args, CYCLE_OPTIONS); - - if (wantsHelp(parsed)) { - console.log(` -Usage: linear cycle list [options] - -List cycles. - -Options: - --team Filter by team key - --limit Max results (default: 50) - --json Output as JSON - -h, --help Show this help -`); - return; - } +export interface ListCyclesOptions { + team?: string; + limit?: number; + json?: boolean; +} +export async function listCycles(options: ListCyclesOptions): Promise { const token = await requireToken(); - const teamKey = getString(parsed, "team"); - const limit = getNumber(parsed, "limit") ?? 50; - const json = await useJson(parsed); + const limit = options.limit ?? 50; + const json = await useJson(options.json); const query = ` query Cycles($first: Int, $filter: CycleFilter) { @@ -60,8 +35,8 @@ Options: `; const filter: Record = {}; - if (teamKey) { - filter.team = { key: { eq: teamKey } }; + if (options.team) { + filter.team = { key: { eq: options.team } }; } const data = await graphql<{ @@ -116,31 +91,14 @@ Options: printTable(headers, rows); } -export async function getCycle(args: string[]): Promise { - const parsed = parseArgs(args, CYCLE_OPTIONS); - - if (wantsHelp(parsed)) { - console.log(` -Usage: linear cycle get [options] - -Get cycle details by UUID. - -Options: - --json Output as JSON - -h, --help Show this help -`); - return; - } - - const cycleId = getPositional(parsed, 0); - if (!cycleId) { - console.error("Error: Cycle ID is required."); - console.error("Usage: linear cycle get "); - process.exit(1); - } +export interface GetCycleOptions { + id: string; + json?: boolean; +} +export async function getCycle(options: GetCycleOptions): Promise { const token = await requireToken(); - const json = await useJson(parsed); + const json = await useJson(options.json); const query = ` query Cycle($id: String!) { @@ -194,7 +152,7 @@ Options: createdAt: string; updatedAt: string; }; - }>(token, query, { id: cycleId }); + }>(token, query, { id: options.id }); const cycle = data.cycle; diff --git a/src/commands/document.ts b/src/commands/document.ts index c59066f..db6f9a8 100644 --- a/src/commands/document.ts +++ b/src/commands/document.ts @@ -1,46 +1,21 @@ import { graphql } from "../api.ts"; -import { - getNumber, - getPositional, - getString, - parseArgs, - wantsHelp, -} from "../args.ts"; import { pad, printTable, requireToken, useJson } from "./shared.ts"; -const DOCUMENT_OPTIONS = { - title: { type: "string" as const }, - content: { type: "string" as const }, - project: { type: "string" as const }, - limit: { type: "string" as const }, - json: { type: "boolean" as const }, -}; - -export async function listDocuments(args: string[]): Promise { - const parsed = parseArgs(args, DOCUMENT_OPTIONS); - - if (wantsHelp(parsed)) { - console.log(` -Usage: linear document list [options] - -List documents. - -Options: - --project Filter by project ID - --limit Number of documents to fetch (default: 50) - --json Output as JSON - -h, --help Show this help -`); - return; - } +export interface ListDocumentsOptions { + project?: string; + limit?: number; + json?: boolean; +} +export async function listDocuments( + options: ListDocumentsOptions, +): Promise { const token = await requireToken(); - const limit = getNumber(parsed, "limit") ?? 50; - const projectId = getString(parsed, "project"); + const limit = options.limit ?? 50; const filter: Record = {}; - if (projectId) { - filter.project = { id: { eq: projectId } }; + if (options.project) { + filter.project = { id: { eq: options.project } }; } const query = ` @@ -80,7 +55,7 @@ Options: const documents = data.documents.nodes; - if (await useJson(parsed)) { + if (await useJson(options.json)) { console.log(JSON.stringify(documents, null, 2)); return; } @@ -101,28 +76,12 @@ Options: printTable(headers, rows); } -export async function getDocument(args: string[]): Promise { - const parsed = parseArgs(args, { json: { type: "boolean" as const } }); - - if (wantsHelp(parsed)) { - console.log(` -Usage: linear document get - -Get document details by ID. - -Options: - --json Output as JSON - -h, --help Show this help -`); - return; - } - - const id = getPositional(parsed, 0); - if (!id) { - console.error("Error: Document ID is required."); - process.exit(1); - } +export interface GetDocumentOptions { + id: string; + json?: boolean; +} +export async function getDocument(options: GetDocumentOptions): Promise { const token = await requireToken(); const query = ` @@ -155,11 +114,11 @@ Options: updatedAt: string; url: string; }; - }>(token, query, { id }); + }>(token, query, { id: options.id }); const doc = data.document; - if (await useJson(parsed)) { + if (await useJson(options.json)) { console.log(JSON.stringify(doc, null, 2)); return; } @@ -188,44 +147,25 @@ Options: } } -export async function createDocument(args: string[]): Promise { - const parsed = parseArgs(args, DOCUMENT_OPTIONS); - - if (wantsHelp(parsed)) { - console.log(` -Usage: linear document create --title --content <markdown> [options] - -Create a new document. - -Options: - --title <title> Document title (required) - --content <markdown> Document content in markdown (required) - --project <id> Project ID - --json Output as JSON - -h, --help Show this help -`); - return; - } - - const title = getString(parsed, "title"); - const content = getString(parsed, "content"); - - if (!title) { - console.error("Error: --title is required."); - process.exit(1); - } - if (!content) { - console.error("Error: --content is required."); - process.exit(1); - } +export interface CreateDocumentOptions { + title: string; + content: string; + project?: string; + json?: boolean; +} +export async function createDocument( + options: CreateDocumentOptions, +): Promise<void> { const token = await requireToken(); - const json = await useJson(parsed); + const json = await useJson(options.json); - const input: Record<string, unknown> = { title, content }; + const input: Record<string, unknown> = { + title: options.title, + content: options.content, + }; - const project = getString(parsed, "project"); - if (project) input.projectId = project; + if (options.project) input.projectId = options.project; const mutation = ` mutation DocumentCreate($input: DocumentCreateInput!) { @@ -272,40 +212,23 @@ Options: console.log(`URL: ${doc.url}`); } -export async function updateDocument(args: string[]): Promise<void> { - const parsed = parseArgs(args, DOCUMENT_OPTIONS); - - if (wantsHelp(parsed)) { - console.log(` -Usage: linear document update <id> [options] - -Update an existing document. - -Options: - --title <title> New title - --content <content> New content - --json Output as JSON - -h, --help Show this help -`); - return; - } - - const id = getPositional(parsed, 0); - if (!id) { - console.error("Error: Document ID is required."); - process.exit(1); - } +export interface UpdateDocumentOptions { + id: string; + title?: string; + content?: string; + json?: boolean; +} +export async function updateDocument( + options: UpdateDocumentOptions, +): Promise<void> { const token = await requireToken(); - const json = await useJson(parsed); + const json = await useJson(options.json); const input: Record<string, unknown> = {}; - const title = getString(parsed, "title"); - if (title) input.title = title; - - const content = getString(parsed, "content"); - if (content) input.content = content; + if (options.title) input.title = options.title; + if (options.content) input.content = options.content; if (Object.keys(input).length === 0) { console.error("Error: No update flags provided."); @@ -334,7 +257,7 @@ Options: url: string; }; }; - }>(token, mutation, { id, input }); + }>(token, mutation, { id: options.id, input }); if (!data.documentUpdate.success) { console.error("Error: Failed to update document."); @@ -352,30 +275,16 @@ Options: console.log(`URL: ${doc.url}`); } -export async function deleteDocument(args: string[]): Promise<void> { - const parsed = parseArgs(args, { json: { type: "boolean" as const } }); - - if (wantsHelp(parsed)) { - console.log(` -Usage: linear document delete <id> - -Delete a document. - -Options: - --json Output as JSON - -h, --help Show this help -`); - return; - } - - const id = getPositional(parsed, 0); - if (!id) { - console.error("Error: Document ID is required."); - process.exit(1); - } +export interface DeleteDocumentOptions { + id: string; + json?: boolean; +} +export async function deleteDocument( + options: DeleteDocumentOptions, +): Promise<void> { const token = await requireToken(); - const json = await useJson(parsed); + const json = await useJson(options.json); const mutation = ` mutation DocumentDelete($id: String!) { @@ -389,7 +298,7 @@ Options: documentDelete: { success: boolean; }; - }>(token, mutation, { id }); + }>(token, mutation, { id: options.id }); if (!data.documentDelete.success) { console.error("Error: Failed to delete document."); @@ -397,9 +306,9 @@ Options: } if (json) { - console.log(JSON.stringify({ id, deleted: true }, null, 2)); + console.log(JSON.stringify({ id: options.id, deleted: true }, null, 2)); return; } - console.log(`Deleted document ${id}`); + console.log(`Deleted document ${options.id}`); } diff --git a/src/commands/graphql.test.ts b/src/commands/graphql.test.ts index 12cda88..6b3d6f3 100644 --- a/src/commands/graphql.test.ts +++ b/src/commands/graphql.test.ts @@ -73,11 +73,10 @@ describe("runGraphql", () => { }) as typeof console.log; try { - await runGraphql([ - "query Issue($id: String!) { issue(id: $id) { id title } }", - "--variables", - '{"id":"ENG-123"}', - ]); + await runGraphql({ + query: "query Issue($id: String!) { issue(id: $id) { id title } }", + variables: '{"id":"ENG-123"}', + }); expect(requestBody).toEqual({ query: "query Issue($id: String!) { issue(id: $id) { id title } }", diff --git a/src/commands/graphql.ts b/src/commands/graphql.ts index 8262ae6..a069b17 100644 --- a/src/commands/graphql.ts +++ b/src/commands/graphql.ts @@ -1,21 +1,14 @@ import { graphqlRequest } from "../api.ts"; -import { getString, parseArgs, wantsHelp } from "../args.ts"; import { CliError } from "../errors.ts"; import { requireToken } from "./shared.ts"; -const GRAPHQL_OPTIONS = { - variables: { type: "string" as const }, -}; - -export async function runGraphql(args: string[]): Promise<void> { - const parsed = parseArgs(args, GRAPHQL_OPTIONS); - - if (wantsHelp(parsed)) { - printGraphqlHelp(); - return; - } +export interface RunGraphqlOptions { + query?: string; + variables?: string; +} - const query = await readQuery(parsed.positionals); +export async function runGraphql(options: RunGraphqlOptions): Promise<void> { + const query = await readQuery(options.query); if (!query) { throw new CliError("A GraphQL query or mutation is required.", { suggestion: @@ -23,7 +16,7 @@ export async function runGraphql(args: string[]): Promise<void> { }); } - const variables = parseVariables(getString(parsed, "variables")); + const variables = parseVariables(options.variables); const token = await requireToken(); const result = await graphqlRequest(token, query, variables); @@ -34,29 +27,6 @@ export async function runGraphql(args: string[]): Promise<void> { console.log(JSON.stringify(result.data ?? null, null, 2)); } -function printGraphqlHelp(): void { - console.log(` -Usage: linear graphql [query] [--variables <json>] - -Run an arbitrary GraphQL query or mutation against Linear. - -Input: - query GraphQL query or mutation string - stdin If query is omitted, read the GraphQL document from stdin - -Options: - --variables <json> Variables object as JSON - -h, --help Show this help - -Examples: - linear graphql 'query { viewer { id name email } }' - linear graphql 'query Issue($id: String!) { issue(id: $id) { id title } }' \\ - --variables '{"id":"ENG-123"}' - linear graphql 'mutation { issueUpdate(id: "...", input: {}) { success } }' - echo 'query { viewer { id name } }' | linear graphql -`); -} - export function parseVariables( raw: string | undefined, ): Record<string, unknown> | undefined { @@ -84,9 +54,9 @@ export function parseVariables( } } -async function readQuery(positionals: string[]): Promise<string | undefined> { - if (positionals.length > 0) { - return positionals.join(" ").trim() || undefined; +async function readQuery(queryFromArgs?: string): Promise<string | undefined> { + if (queryFromArgs) { + return queryFromArgs.trim() || undefined; } if (process.stdin.isTTY) { diff --git a/src/commands/initiative.ts b/src/commands/initiative.ts index 32b84bb..a1ca1b3 100644 --- a/src/commands/initiative.ts +++ b/src/commands/initiative.ts @@ -1,12 +1,6 @@ import { graphql } from "../api.ts"; -import { getNumber, getPositional, parseArgs, wantsHelp } from "../args.ts"; import { printTable, requireToken, useJson } from "./shared.ts"; -const INITIATIVE_OPTIONS = { - limit: { type: "string" as const }, - json: { type: "boolean" as const }, -}; - interface InitiativeNode { id: string; name: string; @@ -22,25 +16,16 @@ interface InitiativeDetail extends InitiativeNode { }; } -export async function listInitiatives(args: string[]): Promise<void> { - const parsed = parseArgs(args, INITIATIVE_OPTIONS); - - if (wantsHelp(parsed)) { - console.log(` -Usage: linear initiative list [options] - -List initiatives. - -Options: - --limit <n> Number of initiatives to fetch (default: 50) - --json Output as JSON - -h, --help Show this help -`); - return; - } +export interface ListInitiativesOptions { + limit?: number; + json?: boolean; +} +export async function listInitiatives( + options: ListInitiativesOptions, +): Promise<void> { const token = await requireToken(); - const limit = getNumber(parsed, "limit") ?? 50; + const limit = options.limit ?? 50; const query = ` query Initiatives($first: Int) { @@ -65,7 +50,7 @@ Options: const initiatives = data.initiatives.nodes; - if (await useJson(parsed)) { + if (await useJson(options.json)) { console.log(JSON.stringify(initiatives, null, 2)); return; } @@ -80,28 +65,14 @@ Options: printTable(headers, rows); } -export async function getInitiative(args: string[]): Promise<void> { - const parsed = parseArgs(args, { json: { type: "boolean" as const } }); - - if (wantsHelp(parsed)) { - console.log(` -Usage: linear initiative get <id> - -Get initiative details by ID. - -Options: - --json Output as JSON - -h, --help Show this help -`); - return; - } - - const initiativeId = getPositional(parsed, 0); - if (!initiativeId) { - console.error("Error: Initiative ID is required."); - process.exit(1); - } +export interface GetInitiativeOptions { + id: string; + json?: boolean; +} +export async function getInitiative( + options: GetInitiativeOptions, +): Promise<void> { const token = await requireToken(); const query = ` @@ -119,12 +90,12 @@ Options: `; const data = await graphql<{ initiative: InitiativeDetail }>(token, query, { - id: initiativeId, + id: options.id, }); const initiative = data.initiative; - if (await useJson(parsed)) { + if (await useJson(options.json)) { console.log(JSON.stringify(initiative, null, 2)); return; } diff --git a/src/commands/issue.ts b/src/commands/issue.ts index 6da455f..b017f40 100644 --- a/src/commands/issue.ts +++ b/src/commands/issue.ts @@ -1,11 +1,4 @@ import { graphql } from "../api.ts"; -import { - getNumber, - getPositional, - getString, - parseArgs, - wantsHelp, -} from "../args.ts"; import { pad, printTable, @@ -15,65 +8,32 @@ import { useJson, } from "./shared.ts"; -const ISSUE_OPTIONS = { - team: { type: "string" as const }, - assignee: { type: "string" as const }, - delegate: { type: "string" as const }, - agent: { type: "string" as const, alias: "delegate" }, - state: { type: "string" as const }, - label: { type: "string" as const }, - limit: { type: "string" as const }, - title: { type: "string" as const }, - description: { type: "string" as const }, - priority: { type: "string" as const }, - project: { type: "string" as const }, - estimate: { type: "string" as const }, - json: { type: "boolean" as const }, -}; - -export async function listIssues(args: string[]): Promise<void> { - const parsed = parseArgs(args, ISSUE_OPTIONS); - - if (wantsHelp(parsed)) { - console.log(` -Usage: linear issue list [flags] - -List issues with optional filters. - -Options: - --team <key> Filter by team key - --assignee <val> Filter by assignee ("me", email, UUID, or name) - --delegate <val> Filter by delegate/agent (UUID, email, or name) - --agent <val> Alias for --delegate - --state <name> Filter by state name - --label <name> Filter by label name - --limit <n> Max results (default: 50) - --json Output as JSON - -h, --help Show this help -`); - return; - } +export interface ListIssuesOptions { + team?: string; + assignee?: string; + delegate?: string; + agent?: string; + state?: string; + label?: string; + limit?: number; + json?: boolean; +} +export async function listIssues(options: ListIssuesOptions): Promise<void> { const token = await requireToken(); - const json = await useJson(parsed); - - const team = getString(parsed, "team"); - const assigneeRaw = getString(parsed, "assignee"); - const delegateRaw = - getString(parsed, "delegate") ?? getString(parsed, "agent"); - const state = getString(parsed, "state"); - const label = getString(parsed, "label"); - const limit = getNumber(parsed, "limit") ?? 50; + const json = await useJson(options.json); + const limit = options.limit ?? 50; const filter: Record<string, unknown> = {}; - if (team) filter.team = { key: { eq: team } }; + if (options.team) filter.team = { key: { eq: options.team } }; + + const assigneeRaw = options.assignee; if (assigneeRaw) { if (assigneeRaw.toLowerCase() === "me") { filter.assignee = { isMe: { eq: true } }; } else if (assigneeRaw.toLowerCase() === "none") { filter.assignee = { null: true }; } else { - // Resolve the assignee try { const assigneeId = await resolveAssignee(token, assigneeRaw); if (assigneeId) { @@ -89,6 +49,8 @@ Options: } } } + + const delegateRaw = options.delegate ?? options.agent; if (delegateRaw) { if (delegateRaw.toLowerCase() === "none") { filter.delegate = { null: true }; @@ -108,8 +70,9 @@ Options: } } } - if (state) filter.state = { name: { eqIgnoreCase: state } }; - if (label) filter.labels = { name: { eqIgnoreCase: label } }; + + if (options.state) filter.state = { name: { eqIgnoreCase: options.state } }; + if (options.label) filter.labels = { name: { eqIgnoreCase: options.label } }; const query = ` query Issues($first: Int, $filter: IssueFilter) { @@ -183,30 +146,14 @@ Options: printTable(headers, rows); } -export async function getIssue(args: string[]): Promise<void> { - const parsed = parseArgs(args, ISSUE_OPTIONS); - - if (wantsHelp(parsed)) { - console.log(` -Usage: linear issue get <identifier> - -Get an issue by identifier (e.g. ENG-123) or UUID. - -Options: - --json Output as JSON - -h, --help Show this help -`); - return; - } - - const id = getPositional(parsed, 0); - if (!id) { - console.error("Error: Issue identifier is required."); - process.exit(1); - } +export interface GetIssueOptions { + identifier: string; + json?: boolean; +} +export async function getIssue(options: GetIssueOptions): Promise<void> { const token = await requireToken(); - const json = await useJson(parsed); + const json = await useJson(options.json); const query = ` query Issue($id: String!) { @@ -254,7 +201,7 @@ Options: updatedAt: string; url: string; }; - }>(token, query, { id }); + }>(token, query, { id: options.identifier }); const issue = data.issue; @@ -304,47 +251,24 @@ Options: } } -export async function createIssue(args: string[]): Promise<void> { - const parsed = parseArgs(args, ISSUE_OPTIONS); - - if (wantsHelp(parsed)) { - console.log(` -Usage: linear issue create --team <key> --title <title> [flags] - -Create a new issue. - -Options: - --team <key> Team key (required) - --title <title> Issue title (required) - --description <text> Issue description - --priority <0-4> Priority (0=none, 1=urgent, 2=high, 3=medium, 4=low) - --assignee <val> Assignee ("me", email, UUID, or name). Use "none" to clear. - --delegate <val> Delegate/agent (UUID, email, or name of app user). Use "none" to clear. - --agent <val> Alias for --delegate - --state <id> State ID - --label <ids> Label IDs (comma-separated) - --project <id> Project ID - --estimate <n> Estimate value - --json Output as JSON - -h, --help Show this help -`); - return; - } - - const teamKey = getString(parsed, "team"); - const title = getString(parsed, "title"); - - if (!teamKey) { - console.error("Error: --team is required."); - process.exit(1); - } - if (!title) { - console.error("Error: --title is required."); - process.exit(1); - } +export interface CreateIssueOptions { + team: string; + title: string; + description?: string; + priority?: number; + assignee?: string; + delegate?: string; + agent?: string; + state?: string; + label?: string; + project?: string; + estimate?: number; + json?: boolean; +} +export async function createIssue(options: CreateIssueOptions): Promise<void> { const token = await requireToken(); - const json = await useJson(parsed); + const json = await useJson(options.json); const teamQuery = ` query TeamByKey($filter: TeamFilter!) { @@ -356,29 +280,25 @@ Options: const teamData = await graphql<{ teams: { nodes: Array<{ id: string; key: string }> }; - }>(token, teamQuery, { filter: { key: { eq: teamKey } } }); + }>(token, teamQuery, { filter: { key: { eq: options.team } } }); const team = teamData.teams.nodes[0]; if (!team) { - console.error(`Error: Team with key "${teamKey}" not found.`); + console.error(`Error: Team with key "${options.team}" not found.`); process.exit(1); } const input: Record<string, unknown> = { teamId: team.id, - title, + title: options.title, }; - const description = getString(parsed, "description"); - if (description) input.description = description; + if (options.description) input.description = options.description; + if (options.priority != null) input.priority = options.priority; - const priority = getNumber(parsed, "priority"); - if (priority != null) input.priority = priority; - - const assigneeRaw = getString(parsed, "assignee"); - if (assigneeRaw) { + if (options.assignee) { try { - const assigneeId = await resolveAssignee(token, assigneeRaw); + const assigneeId = await resolveAssignee(token, options.assignee); if (assigneeId) { input.assigneeId = assigneeId; } @@ -390,8 +310,7 @@ Options: } } - const delegateRaw = - getString(parsed, "delegate") ?? getString(parsed, "agent"); + const delegateRaw = options.delegate ?? options.agent; if (delegateRaw) { try { const delegateId = await resolveDelegate(token, delegateRaw); @@ -406,17 +325,10 @@ Options: } } - const state = getString(parsed, "state"); - if (state) input.stateId = state; - - const label = getString(parsed, "label"); - if (label) input.labelIds = label.split(","); - - const project = getString(parsed, "project"); - if (project) input.projectId = project; - - const estimate = getNumber(parsed, "estimate"); - if (estimate != null) input.estimate = estimate; + if (options.state) input.stateId = options.state; + if (options.label) input.labelIds = options.label.split(","); + if (options.project) input.projectId = options.project; + if (options.estimate != null) input.estimate = options.estimate; const mutation = ` mutation IssueCreate($input: IssueCreateInput!) { @@ -475,63 +387,38 @@ Options: console.log(`URL: ${issue.url}`); } -export async function updateIssue(args: string[]): Promise<void> { - const parsed = parseArgs(args, ISSUE_OPTIONS); - - if (wantsHelp(parsed)) { - console.log(` -Usage: linear issue update <identifier> [flags] - -Update an existing issue. - -Options: - --title <title> New title - --description <text> New description - --priority <0-4> New priority - --state <id> New state ID - --assignee <val> New assignee ("me", email, UUID, or name). Use "none" to clear. - --delegate <val> New delegate/agent (UUID, email, or name of app user). Use "none" to clear. - --agent <val> Alias for --delegate - --label <ids> New label IDs (comma-separated) - --project <id> New project ID - --estimate <n> New estimate value - --json Output as JSON - -h, --help Show this help -`); - return; - } - - const id = getPositional(parsed, 0); - if (!id) { - console.error("Error: Issue identifier is required."); - process.exit(1); - } +export interface UpdateIssueOptions { + identifier: string; + title?: string; + description?: string; + priority?: number; + state?: string; + assignee?: string; + delegate?: string; + agent?: string; + label?: string; + project?: string; + estimate?: number; + json?: boolean; +} +export async function updateIssue(options: UpdateIssueOptions): Promise<void> { const token = await requireToken(); - const json = await useJson(parsed); + const json = await useJson(options.json); const input: Record<string, unknown> = {}; - const title = getString(parsed, "title"); - if (title) input.title = title; - - const description = getString(parsed, "description"); - if (description) input.description = description; - - const priority = getNumber(parsed, "priority"); - if (priority != null) input.priority = priority; + if (options.title) input.title = options.title; + if (options.description) input.description = options.description; + if (options.priority != null) input.priority = options.priority; + if (options.state) input.stateId = options.state; - const state = getString(parsed, "state"); - if (state) input.stateId = state; - - const assigneeRaw = getString(parsed, "assignee"); - if (assigneeRaw) { + if (options.assignee) { try { - const assigneeId = await resolveAssignee(token, assigneeRaw); + const assigneeId = await resolveAssignee(token, options.assignee); if (assigneeId) { input.assigneeId = assigneeId; } else { - // Clearing assignee input.assigneeId = null; } } catch (err) { @@ -542,15 +429,13 @@ Options: } } - const delegateRaw = - getString(parsed, "delegate") ?? getString(parsed, "agent"); + const delegateRaw = options.delegate ?? options.agent; if (delegateRaw) { try { const delegateId = await resolveDelegate(token, delegateRaw); if (delegateId) { input.delegateId = delegateId; } else { - // Clearing delegate input.delegateId = null; } } catch (err) { @@ -561,14 +446,9 @@ Options: } } - const label = getString(parsed, "label"); - if (label) input.labelIds = label.split(","); - - const project = getString(parsed, "project"); - if (project) input.projectId = project; - - const estimate = getNumber(parsed, "estimate"); - if (estimate != null) input.estimate = estimate; + if (options.label) input.labelIds = options.label.split(","); + if (options.project) input.projectId = options.project; + if (options.estimate != null) input.estimate = options.estimate; if (Object.keys(input).length === 0) { console.error("Error: No update flags provided."); @@ -605,7 +485,7 @@ Options: url: string; }; }; - }>(token, mutation, { id, input }); + }>(token, mutation, { id: options.identifier, input }); if (!data.issueUpdate.success) { console.error("Error: Failed to update issue."); @@ -630,30 +510,14 @@ Options: console.log(`URL: ${issue.url}`); } -export async function closeIssue(args: string[]): Promise<void> { - const parsed = parseArgs(args, ISSUE_OPTIONS); - - if (wantsHelp(parsed)) { - console.log(` -Usage: linear issue close <identifier> - -Close an issue by setting it to the first completed state. - -Options: - --json Output as JSON - -h, --help Show this help -`); - return; - } - - const id = getPositional(parsed, 0); - if (!id) { - console.error("Error: Issue identifier is required."); - process.exit(1); - } +export interface CloseIssueOptions { + identifier: string; + json?: boolean; +} +export async function closeIssue(options: CloseIssueOptions): Promise<void> { const token = await requireToken(); - const json = await useJson(parsed); + const json = await useJson(options.json); const issueQuery = ` query Issue($id: String!) { @@ -679,7 +543,7 @@ Options: }; }; }; - }>(token, issueQuery, { id }); + }>(token, issueQuery, { id: options.identifier }); const issue = issueData.issue; const completedState = issue.team.states.nodes.find( diff --git a/src/commands/label.ts b/src/commands/label.ts index a773256..748ab7c 100644 --- a/src/commands/label.ts +++ b/src/commands/label.ts @@ -1,47 +1,19 @@ import { graphql } from "../api.ts"; -import { - getNumber, - getPositional, - getString, - parseArgs, - wantsHelp, -} from "../args.ts"; import { printTable, requireToken, useJson } from "./shared.ts"; -const LABEL_OPTIONS = { - team: { type: "string" as const }, - name: { type: "string" as const }, - color: { type: "string" as const }, - description: { type: "string" as const }, - limit: { type: "string" as const }, - json: { type: "boolean" as const }, -}; - -export async function listLabels(args: string[]): Promise<void> { - const parsed = parseArgs(args, LABEL_OPTIONS); - - if (wantsHelp(parsed)) { - console.log(` -Usage: linear label list [options] - -List issue labels. - -Options: - --team <key> Filter by team key - --limit <n> Maximum number of labels to return (default: 50) - --json Output as JSON - -h, --help Show this help -`); - return; - } +export interface ListLabelsOptions { + team?: string; + limit?: number; + json?: boolean; +} +export async function listLabels(options: ListLabelsOptions): Promise<void> { const token = await requireToken(); - const json = await useJson(parsed); - const teamKey = getString(parsed, "team"); - const limit = getNumber(parsed, "limit") ?? 50; + const json = await useJson(options.json); + const limit = options.limit ?? 50; const filter: Record<string, unknown> = {}; - if (teamKey) filter.team = { key: { eq: teamKey } }; + if (options.team) filter.team = { key: { eq: options.team } }; const query = ` query IssueLabels($first: Int, $filter: IssueLabelFilter) { @@ -96,48 +68,25 @@ Options: printTable(headers, rows); } -export async function createLabel(args: string[]): Promise<void> { - const parsed = parseArgs(args, LABEL_OPTIONS); - - if (wantsHelp(parsed)) { - console.log(` -Usage: linear label create --name <name> --color <hex> [options] - -Create a new issue label. - -Options: - --name <name> Label name (required) - --color <hex> Label color hex (required) - --team <key> Team key to scope the label to - --description <text> Label description - --json Output as JSON - -h, --help Show this help -`); - return; - } +export interface CreateLabelOptions { + name: string; + color: string; + team?: string; + description?: string; + json?: boolean; +} +export async function createLabel(options: CreateLabelOptions): Promise<void> { const token = await requireToken(); - const json = await useJson(parsed); + const json = await useJson(options.json); - const name = getString(parsed, "name"); - if (!name) { - console.error("Error: --name is required."); - process.exit(1); - } - - const color = getString(parsed, "color"); - if (!color) { - console.error("Error: --color is required."); - process.exit(1); - } + const input: Record<string, unknown> = { + name: options.name, + color: options.color, + }; + if (options.description) input.description = options.description; - const teamKey = getString(parsed, "team"); - const description = getString(parsed, "description"); - - const input: Record<string, unknown> = { name, color }; - if (description) input.description = description; - - if (teamKey) { + if (options.team) { const teamQuery = ` query Teams($filter: TeamFilter!) { teams(filter: $filter, first: 1) { @@ -148,10 +97,10 @@ Options: const teamData = await graphql<{ teams: { nodes: Array<{ id: string }> }; - }>(token, teamQuery, { filter: { key: { eq: teamKey } } }); + }>(token, teamQuery, { filter: { key: { eq: options.team } } }); if (teamData.teams.nodes.length === 0) { - console.error(`Error: Team "${teamKey}" not found.`); + console.error(`Error: Team "${options.team}" not found.`); process.exit(1); } @@ -199,45 +148,23 @@ Options: console.log(`Created label "${label.name}" (${label.color})`); } -export async function updateLabel(args: string[]): Promise<void> { - const parsed = parseArgs(args, LABEL_OPTIONS); - - if (wantsHelp(parsed)) { - console.log(` -Usage: linear label update <id> [options] - -Update an existing issue label. - -Options: - --name <name> New label name - --color <hex> New label color hex - --description <text> New label description - --json Output as JSON - -h, --help Show this help -`); - return; - } - - const id = getPositional(parsed, 0); - if (!id) { - console.error("Error: Label ID is required."); - console.error("Usage: linear label update <id>"); - process.exit(1); - } +export interface UpdateLabelOptions { + id: string; + name?: string; + color?: string; + description?: string; + json?: boolean; +} +export async function updateLabel(options: UpdateLabelOptions): Promise<void> { const token = await requireToken(); - const json = await useJson(parsed); + const json = await useJson(options.json); const input: Record<string, unknown> = {}; - const name = getString(parsed, "name"); - if (name) input.name = name; - - const color = getString(parsed, "color"); - if (color) input.color = color; - - const description = getString(parsed, "description"); - if (description) input.description = description; + if (options.name) input.name = options.name; + if (options.color) input.color = options.color; + if (options.description) input.description = options.description; if (Object.keys(input).length === 0) { console.error("Error: No update flags provided."); @@ -266,7 +193,7 @@ Options: color: string; }; }; - }>(token, mutation, { id, input }); + }>(token, mutation, { id: options.id, input }); if (!data.issueLabelUpdate.success) { console.error("Error: Failed to update label."); @@ -283,31 +210,14 @@ Options: console.log(`Updated label "${label.name}" (${label.color})`); } -export async function deleteLabel(args: string[]): Promise<void> { - const parsed = parseArgs(args, LABEL_OPTIONS); - - if (wantsHelp(parsed)) { - console.log(` -Usage: linear label delete <id> - -Delete (archive) an issue label. - -Options: - --json Output as JSON - -h, --help Show this help -`); - return; - } - - const id = getPositional(parsed, 0); - if (!id) { - console.error("Error: Label ID is required."); - console.error("Usage: linear label delete <id>"); - process.exit(1); - } +export interface DeleteLabelOptions { + id: string; + json?: boolean; +} +export async function deleteLabel(options: DeleteLabelOptions): Promise<void> { const token = await requireToken(); - const json = await useJson(parsed); + const json = await useJson(options.json); const mutation = ` mutation IssueLabelArchive($id: String!) { @@ -319,7 +229,7 @@ Options: const data = await graphql<{ issueLabelArchive: { success: boolean }; - }>(token, mutation, { id }); + }>(token, mutation, { id: options.id }); if (!data.issueLabelArchive.success) { console.error("Error: Failed to delete label."); @@ -327,9 +237,9 @@ Options: } if (json) { - console.log(JSON.stringify({ id, deleted: true }, null, 2)); + console.log(JSON.stringify({ id: options.id, deleted: true }, null, 2)); return; } - console.log(`Deleted label ${id}`); + console.log(`Deleted label ${options.id}`); } diff --git a/src/commands/milestone.ts b/src/commands/milestone.ts index ca72edd..9a17159 100644 --- a/src/commands/milestone.ts +++ b/src/commands/milestone.ts @@ -1,48 +1,22 @@ import { graphql } from "../api.ts"; -import { - getNumber, - getPositional, - getString, - parseArgs, - wantsHelp, -} from "../args.ts"; import { printTable, requireToken, useJson } from "./shared.ts"; -const MILESTONE_OPTIONS = { - project: { type: "string" as const }, - name: { type: "string" as const }, - description: { type: "string" as const }, - targetDate: { type: "string" as const }, - limit: { type: "string" as const }, - json: { type: "boolean" as const }, -}; - -export async function listMilestones(args: string[]): Promise<void> { - const parsed = parseArgs(args, MILESTONE_OPTIONS); - - if (wantsHelp(parsed)) { - console.log(` -Usage: linear milestone list [options] - -List project milestones. - -Options: - --project <id> Filter by project ID - --limit <n> Max results (default: 50) - --json Output as JSON - -h, --help Show this help -`); - return; - } +export interface ListMilestonesOptions { + project?: string; + limit?: number; + json?: boolean; +} +export async function listMilestones( + options: ListMilestonesOptions, +): Promise<void> { const token = await requireToken(); - const projectId = getString(parsed, "project"); - const limit = getNumber(parsed, "limit") ?? 50; - const json = await useJson(parsed); + const limit = options.limit ?? 50; + const json = await useJson(options.json); const filter: Record<string, unknown> = {}; - if (projectId) { - filter.project = { id: { eq: projectId } }; + if (options.project) { + filter.project = { id: { eq: options.project } }; } const query = ` @@ -101,30 +75,16 @@ Options: printTable(headers, rows); } -export async function getMilestone(args: string[]): Promise<void> { - const parsed = parseArgs(args, MILESTONE_OPTIONS); - - if (wantsHelp(parsed)) { - console.log(` -Usage: linear milestone get <id> - -Get milestone details by UUID. - -Options: - --json Output as JSON - -h, --help Show this help -`); - return; - } - - const milestoneId = getPositional(parsed, 0); - if (!milestoneId) { - console.error("Error: Milestone ID is required."); - process.exit(1); - } +export interface GetMilestoneOptions { + id: string; + json?: boolean; +} +export async function getMilestone( + options: GetMilestoneOptions, +): Promise<void> { const token = await requireToken(); - const json = await useJson(parsed); + const json = await useJson(options.json); const query = ` query ProjectMilestone($id: String!) { @@ -166,7 +126,7 @@ Options: createdAt: string; updatedAt: string; }; - }>(token, query, { id: milestoneId }); + }>(token, query, { id: options.id }); const milestone = data.projectMilestone; @@ -197,48 +157,27 @@ Options: } } -export async function createMilestone(args: string[]): Promise<void> { - const parsed = parseArgs(args, MILESTONE_OPTIONS); - - if (wantsHelp(parsed)) { - console.log(` -Usage: linear milestone create --project <id> --name <name> [options] - -Create a new project milestone. - -Options: - --project <id> Project ID (required) - --name <name> Milestone name (required) - --description <text> Description - --targetDate <date> Target date (YYYY-MM-DD) - --json Output as JSON - -h, --help Show this help -`); - return; - } +export interface CreateMilestoneOptions { + project: string; + name: string; + description?: string; + targetDate?: string; + json?: boolean; +} +export async function createMilestone( + options: CreateMilestoneOptions, +): Promise<void> { const token = await requireToken(); - const json = await useJson(parsed); + const json = await useJson(options.json); - const projectId = getString(parsed, "project"); - if (!projectId) { - console.error("Error: --project is required."); - process.exit(1); - } - - const name = getString(parsed, "name"); - if (!name) { - console.error("Error: --name is required."); - process.exit(1); - } + const input: Record<string, unknown> = { + projectId: options.project, + name: options.name, + }; - const input: Record<string, unknown> = { projectId, name }; - - const description = getString(parsed, "description"); - if (description) input.description = description; - - const targetDate = getString(parsed, "targetDate"); - if (targetDate) input.targetDate = targetDate; + if (options.description) input.description = options.description; + if (options.targetDate) input.targetDate = options.targetDate; const mutation = ` mutation ProjectMilestoneCreate($input: ProjectMilestoneCreateInput!) { @@ -283,44 +222,25 @@ Options: console.log(`Target Date: ${milestone.targetDate ?? "Not set"}`); } -export async function updateMilestone(args: string[]): Promise<void> { - const parsed = parseArgs(args, MILESTONE_OPTIONS); - - if (wantsHelp(parsed)) { - console.log(` -Usage: linear milestone update <id> [options] - -Update an existing project milestone. - -Options: - --name <name> New name - --description <text> New description - --targetDate <date> New target date (YYYY-MM-DD) - --json Output as JSON - -h, --help Show this help -`); - return; - } - - const id = getPositional(parsed, 0); - if (!id) { - console.error("Error: Milestone ID is required."); - process.exit(1); - } +export interface UpdateMilestoneOptions { + id: string; + name?: string; + description?: string; + targetDate?: string; + json?: boolean; +} +export async function updateMilestone( + options: UpdateMilestoneOptions, +): Promise<void> { const token = await requireToken(); - const json = await useJson(parsed); + const json = await useJson(options.json); const input: Record<string, unknown> = {}; - const name = getString(parsed, "name"); - if (name) input.name = name; - - const description = getString(parsed, "description"); - if (description) input.description = description; - - const targetDate = getString(parsed, "targetDate"); - if (targetDate) input.targetDate = targetDate; + if (options.name) input.name = options.name; + if (options.description) input.description = options.description; + if (options.targetDate) input.targetDate = options.targetDate; if (Object.keys(input).length === 0) { console.error("Error: No update flags provided."); @@ -349,7 +269,7 @@ Options: targetDate: string | null; }; }; - }>(token, mutation, { id, input }); + }>(token, mutation, { id: options.id, input }); if (!data.projectMilestoneUpdate.success) { console.error("Error: Failed to update milestone."); @@ -367,30 +287,16 @@ Options: console.log(`Target Date: ${milestone.targetDate ?? "Not set"}`); } -export async function deleteMilestone(args: string[]): Promise<void> { - const parsed = parseArgs(args, MILESTONE_OPTIONS); - - if (wantsHelp(parsed)) { - console.log(` -Usage: linear milestone delete <id> - -Delete a project milestone. - -Options: - --json Output as JSON - -h, --help Show this help -`); - return; - } - - const id = getPositional(parsed, 0); - if (!id) { - console.error("Error: Milestone ID is required."); - process.exit(1); - } +export interface DeleteMilestoneOptions { + id: string; + json?: boolean; +} +export async function deleteMilestone( + options: DeleteMilestoneOptions, +): Promise<void> { const token = await requireToken(); - const json = await useJson(parsed); + const json = await useJson(options.json); const mutation = ` mutation ProjectMilestoneDelete($id: String!) { @@ -404,7 +310,7 @@ Options: projectMilestoneDelete: { success: boolean; }; - }>(token, mutation, { id }); + }>(token, mutation, { id: options.id }); if (!data.projectMilestoneDelete.success) { console.error("Error: Failed to delete milestone."); @@ -412,9 +318,9 @@ Options: } if (json) { - console.log(JSON.stringify({ deleted: true, id }, null, 2)); + console.log(JSON.stringify({ deleted: true, id: options.id }, null, 2)); return; } - console.log(`Deleted milestone ${id}`); + console.log(`Deleted milestone ${options.id}`); } diff --git a/src/commands/project.ts b/src/commands/project.ts index 48425af..18bd91e 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -1,19 +1,6 @@ import { graphql } from "../api.ts"; -import { - getNumber, - getPositional, - getString, - parseArgs, - wantsHelp, -} from "../args.ts"; import { printTable, requireToken, useJson } from "./shared.ts"; -const PROJECT_OPTIONS = { - limit: { type: "string" as const }, - status: { type: "string" as const }, - json: { type: "boolean" as const }, -}; - interface ProjectNode { id: string; name: string; @@ -39,31 +26,21 @@ interface ProjectDetail extends ProjectNode { url: string; } -export async function listProjects(args: string[]): Promise<void> { - const parsed = parseArgs(args, PROJECT_OPTIONS); - - if (wantsHelp(parsed)) { - console.log(` -Usage: linear project list [options] - -List projects. - -Options: - --limit <n> Number of projects to fetch (default: 50) - --status <s> Filter by status (planned, started, paused, completed, canceled) - --json Output as JSON - -h, --help Show this help -`); - return; - } +export interface ListProjectsOptions { + limit?: number; + status?: string; + json?: boolean; +} +export async function listProjects( + options: ListProjectsOptions, +): Promise<void> { const token = await requireToken(); - const limit = getNumber(parsed, "limit") ?? 50; - const status = getString(parsed, "status"); + const limit = options.limit ?? 50; const filter: Record<string, unknown> = {}; - if (status) { - filter.state = { eq: status }; + if (options.status) { + filter.state = { eq: options.status }; } const query = ` @@ -97,7 +74,7 @@ Options: const projects = data.projects.nodes; - if (await useJson(parsed)) { + if (await useJson(options.json)) { console.log(JSON.stringify(projects, null, 2)); return; } @@ -127,28 +104,12 @@ Options: printTable(headers, rows); } -export async function getProject(args: string[]): Promise<void> { - const parsed = parseArgs(args, { json: { type: "boolean" as const } }); - - if (wantsHelp(parsed)) { - console.log(` -Usage: linear project get <project-id> - -Get project details by UUID or slug. - -Options: - --json Output as JSON - -h, --help Show this help -`); - return; - } - - const projectId = getPositional(parsed, 0); - if (!projectId) { - console.error("Error: Project ID or slug is required."); - process.exit(1); - } +export interface GetProjectOptions { + id: string; + json?: boolean; +} +export async function getProject(options: GetProjectOptions): Promise<void> { const token = await requireToken(); const query = ` @@ -174,12 +135,12 @@ Options: `; const data = await graphql<{ project: ProjectDetail }>(token, query, { - id: projectId, + id: options.id, }); const project = data.project; - if (await useJson(parsed)) { + if (await useJson(options.json)) { console.log(JSON.stringify(project, null, 2)); return; } diff --git a/src/commands/search.ts b/src/commands/search.ts index 5a00b37..9b2ef4d 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -1,50 +1,22 @@ import { graphql } from "../api.ts"; -import { - getNumber, - getPositional, - getString, - parseArgs, - wantsHelp, -} from "../args.ts"; import { printTable, requireToken, useJson } from "./shared.ts"; -const OPTIONS = { - team: { type: "string" as const }, - limit: { type: "string" as const }, - json: { type: "boolean" as const }, -}; - -export async function searchIssues(args: string[]): Promise<void> { - const parsed = parseArgs(args, OPTIONS); - - if (wantsHelp(parsed)) { - console.log(` -Usage: linear search issues <term> [flags] - -Search issues by term. - -Options: - --team <key> Filter by team key - --limit <n> Max results (default: 25) - --json Output as JSON - -h, --help Show this help -`); - return; - } - - const term = getPositional(parsed, 0); - if (!term) { - console.error("Error: Search term is required."); - process.exit(1); - } +export interface SearchIssuesOptions { + term: string; + team?: string; + limit?: number; + json?: boolean; +} +export async function searchIssues( + options: SearchIssuesOptions, +): Promise<void> { const token = await requireToken(); - const json = await useJson(parsed); - const teamKey = getString(parsed, "team"); - const limit = getNumber(parsed, "limit") ?? 25; + const json = await useJson(options.json); + const limit = options.limit ?? 25; - const filter: Record<string, unknown> | undefined = teamKey - ? { team: { key: { eq: teamKey } } } + const filter: Record<string, unknown> | undefined = options.team + ? { team: { key: { eq: options.team } } } : undefined; const query = ` @@ -77,7 +49,7 @@ Options: priorityLabel: string; }>; }; - }>(token, query, { term, first: limit, filter }); + }>(token, query, { term: options.term, first: limit, filter }); const issues = data.searchIssues.nodes; @@ -103,32 +75,18 @@ Options: printTable(headers, rows); } -export async function searchDocuments(args: string[]): Promise<void> { - const parsed = parseArgs(args, OPTIONS); - - if (wantsHelp(parsed)) { - console.log(` -Usage: linear search documents <term> [flags] - -Search documents by term. - -Options: - --limit <n> Max results (default: 25) - --json Output as JSON - -h, --help Show this help -`); - return; - } - - const term = getPositional(parsed, 0); - if (!term) { - console.error("Error: Search term is required."); - process.exit(1); - } +export interface SearchDocumentsOptions { + term: string; + limit?: number; + json?: boolean; +} +export async function searchDocuments( + options: SearchDocumentsOptions, +): Promise<void> { const token = await requireToken(); - const json = await useJson(parsed); - const limit = getNumber(parsed, "limit") ?? 25; + const json = await useJson(options.json); + const limit = options.limit ?? 25; const query = ` query SearchDocuments($term: String!, $first: Int) { @@ -154,7 +112,7 @@ Options: createdAt: string; }>; }; - }>(token, query, { term, first: limit }); + }>(token, query, { term: options.term, first: limit }); const documents = data.searchDocuments.nodes; @@ -179,32 +137,18 @@ Options: printTable(headers, rows); } -export async function searchProjects(args: string[]): Promise<void> { - const parsed = parseArgs(args, OPTIONS); - - if (wantsHelp(parsed)) { - console.log(` -Usage: linear search projects <term> [flags] - -Search projects by term. - -Options: - --limit <n> Max results (default: 25) - --json Output as JSON - -h, --help Show this help -`); - return; - } - - const term = getPositional(parsed, 0); - if (!term) { - console.error("Error: Search term is required."); - process.exit(1); - } +export interface SearchProjectsOptions { + term: string; + limit?: number; + json?: boolean; +} +export async function searchProjects( + options: SearchProjectsOptions, +): Promise<void> { const token = await requireToken(); - const json = await useJson(parsed); - const limit = getNumber(parsed, "limit") ?? 25; + const json = await useJson(options.json); + const limit = options.limit ?? 25; const query = ` query SearchProjects($term: String!, $first: Int) { @@ -232,7 +176,7 @@ Options: teams: { nodes: Array<{ key: string }> }; }>; }; - }>(token, query, { term, first: limit }); + }>(token, query, { term: options.term, first: limit }); const projects = data.searchProjects.nodes; diff --git a/src/commands/shared.ts b/src/commands/shared.ts index d1133b8..a9d1884 100644 --- a/src/commands/shared.ts +++ b/src/commands/shared.ts @@ -1,7 +1,6 @@ +import Table from "cli-table3"; import type { ResolvedAuth } from "../api.ts"; import { graphql } from "../api.ts"; -import type { ParsedArgs } from "../args.ts"; -import { getBoolean } from "../args.ts"; import { loadMergedConfig, resolveAuth } from "../config.ts"; export type { ResolvedAuth }; @@ -215,8 +214,8 @@ export async function resolveDelegate( return user.id; } -export async function useJson(parsed: ParsedArgs): Promise<boolean> { - if (getBoolean(parsed, "json")) return true; +export async function useJson(jsonFlag?: boolean): Promise<boolean> { + if (jsonFlag) return true; const { config } = await loadMergedConfig({ workspace: getWorkspaceContext(), }); @@ -238,12 +237,35 @@ export function truncate(str: string, len: number): string { } export function printTable(headers: string[], rows: string[][]): void { - const widths = headers.map((h, i) => - Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length)), - ); + const table = new Table({ + head: headers, + style: { + compact: true, + head: [], + border: [], + }, + chars: { + top: "", + "top-mid": "", + "top-left": "", + "top-right": "", + bottom: "", + "bottom-mid": "", + "bottom-left": "", + "bottom-right": "", + left: "", + "left-mid": "", + mid: "", + "mid-mid": "", + right: "", + "right-mid": "", + middle: " ", + }, + }); - console.log(headers.map((h, i) => pad(h, widths[i] ?? 0)).join(" ")); for (const row of rows) { - console.log(row.map((cell, i) => pad(cell, widths[i] ?? 0)).join(" ")); + table.push(row); } + + console.log(table.toString()); } diff --git a/src/commands/state.ts b/src/commands/state.ts index 70709a9..6213c14 100644 --- a/src/commands/state.ts +++ b/src/commands/state.ts @@ -1,35 +1,16 @@ import { graphql } from "../api.ts"; -import { getNumber, getString, parseArgs, wantsHelp } from "../args.ts"; import { printTable, requireToken, useJson } from "./shared.ts"; -const STATE_OPTIONS = { - team: { type: "string" as const }, - limit: { type: "string" as const }, - json: { type: "boolean" as const }, -}; - -export async function listStates(args: string[]): Promise<void> { - const parsed = parseArgs(args, STATE_OPTIONS); - - if (wantsHelp(parsed)) { - console.log(` -Usage: linear state list [options] - -List workflow states. - -Options: - --team <key> Filter by team key - --limit <n> Max results (default: 50) - --json Output as JSON - -h, --help Show this help -`); - return; - } +export interface ListStatesOptions { + team?: string; + limit?: number; + json?: boolean; +} +export async function listStates(options: ListStatesOptions): Promise<void> { const token = await requireToken(); - const teamKey = getString(parsed, "team"); - const limit = getNumber(parsed, "limit") ?? 50; - const json = await useJson(parsed); + const limit = options.limit ?? 50; + const json = await useJson(options.json); const query = ` query WorkflowStates($first: Int, $filter: WorkflowStateFilter) { @@ -47,8 +28,8 @@ Options: `; const filter: Record<string, unknown> = {}; - if (teamKey) { - filter.team = { key: { eq: teamKey } }; + if (options.team) { + filter.team = { key: { eq: options.team } }; } const data = await graphql<{ diff --git a/src/commands/team.ts b/src/commands/team.ts index fcf81ef..79a3c38 100644 --- a/src/commands/team.ts +++ b/src/commands/team.ts @@ -1,12 +1,6 @@ import { graphql } from "../api.ts"; -import { getNumber, getPositional, parseArgs, wantsHelp } from "../args.ts"; import { printTable, requireToken, useJson } from "./shared.ts"; -const TEAM_OPTIONS = { - limit: { type: "string" as const }, - json: { type: "boolean" as const }, -}; - interface TeamMember { id: string; name: string; @@ -39,26 +33,15 @@ interface Team { updatedAt?: string; } -export async function listTeams(args: string[]): Promise<void> { - const parsed = parseArgs(args, TEAM_OPTIONS); - - if (wantsHelp(parsed)) { - console.log(` -Usage: linear team list [options] - -List teams in the workspace. - -Options: - --limit <n> Maximum number of teams to return (default: 50) - --json Output as JSON - -h, --help Show this help -`); - return; - } +export interface ListTeamsOptions { + limit?: number; + json?: boolean; +} +export async function listTeams(options: ListTeamsOptions): Promise<void> { const token = await requireToken(); - const limit = getNumber(parsed, "limit") ?? 50; - const json = await useJson(parsed); + const limit = options.limit ?? 50; + const json = await useJson(options.json); const query = ` query Teams($first: Int) { @@ -103,36 +86,16 @@ Options: printTable(headers, rows); } -export async function getTeam(args: string[]): Promise<void> { - const parsed = parseArgs(args, TEAM_OPTIONS); - - if (wantsHelp(parsed)) { - console.log(` -Usage: linear team get <team-key-or-id> - -Get details for a specific team. - -Arguments: - team-key-or-id Team key (e.g. "ENG") or team UUID - -Options: - --json Output as JSON - -h, --help Show this help -`); - return; - } - - const teamRef = getPositional(parsed, 0); - if (!teamRef) { - console.error("Error: Team key or ID is required."); - console.error("Usage: linear team get <team-key-or-id>"); - process.exit(1); - } +export interface GetTeamOptions { + teamRef: string; + json?: boolean; +} +export async function getTeam(options: GetTeamOptions): Promise<void> { const token = await requireToken(); - const json = await useJson(parsed); + const json = await useJson(options.json); - const isUuid = teamRef.includes("-") && teamRef.length > 20; + const isUuid = options.teamRef.includes("-") && options.teamRef.length > 20; let team: Team; @@ -155,7 +118,9 @@ Options: } `; - const data = await graphql<{ team: Team }>(token, query, { id: teamRef }); + const data = await graphql<{ team: Team }>(token, query, { + id: options.teamRef, + }); team = data.team; } else { const query = ` @@ -179,11 +144,11 @@ Options: `; const data = await graphql<{ teams: { nodes: Team[] } }>(token, query, { - filter: { key: { eq: teamRef.toUpperCase() } }, + filter: { key: { eq: options.teamRef.toUpperCase() } }, }); if (data.teams.nodes.length === 0) { - console.error(`Error: Team "${teamRef}" not found.`); + console.error(`Error: Team "${options.teamRef}" not found.`); process.exit(1); } diff --git a/src/commands/user.ts b/src/commands/user.ts index e6d750f..ee448dc 100644 --- a/src/commands/user.ts +++ b/src/commands/user.ts @@ -1,12 +1,6 @@ import { graphql } from "../api.ts"; -import { getNumber, getPositional, parseArgs, wantsHelp } from "../args.ts"; import { pad, printTable, requireToken, useJson } from "./shared.ts"; -const USER_OPTIONS = { - limit: { type: "string" as const }, - json: { type: "boolean" as const }, -}; - interface User { id: string; name: string; @@ -20,26 +14,15 @@ interface User { updatedAt?: string; } -export async function listUsers(args: string[]): Promise<void> { - const parsed = parseArgs(args, USER_OPTIONS); - - if (wantsHelp(parsed)) { - console.log(` -Usage: linear user list [options] - -List users in the workspace. - -Options: - --limit <n> Maximum number of users to return (default: 50) - --json Output as JSON - -h, --help Show this help -`); - return; - } +export interface ListUsersOptions { + limit?: number; + json?: boolean; +} +export async function listUsers(options: ListUsersOptions): Promise<void> { const token = await requireToken(); - const limit = getNumber(parsed, "limit") ?? 50; - const json = await useJson(parsed); + const limit = options.limit ?? 50; + const json = await useJson(options.json); const query = ` query Users($first: Int) { @@ -86,34 +69,14 @@ Options: printTable(headers, rows); } -export async function getUser(args: string[]): Promise<void> { - const parsed = parseArgs(args, USER_OPTIONS); - - if (wantsHelp(parsed)) { - console.log(` -Usage: linear user get <id> - -Get details for a specific user. - -Arguments: - id User UUID - -Options: - --json Output as JSON - -h, --help Show this help -`); - return; - } - - const userId = getPositional(parsed, 0); - if (!userId) { - console.error("Error: User ID is required."); - console.error("Usage: linear user get <id>"); - process.exit(1); - } +export interface GetUserOptions { + id: string; + json?: boolean; +} +export async function getUser(options: GetUserOptions): Promise<void> { const token = await requireToken(); - const json = await useJson(parsed); + const json = await useJson(options.json); const query = ` query User($id: String!) { @@ -132,7 +95,7 @@ Options: } `; - const data = await graphql<{ user: User }>(token, query, { id: userId }); + const data = await graphql<{ user: User }>(token, query, { id: options.id }); const user = data.user; if (json) { @@ -143,24 +106,13 @@ Options: printUserDetail(user); } -export async function me(args: string[]): Promise<void> { - const parsed = parseArgs(args, USER_OPTIONS); - - if (wantsHelp(parsed)) { - console.log(` -Usage: linear user me - -Show the currently authenticated user. - -Options: - --json Output as JSON - -h, --help Show this help -`); - return; - } +export interface MeOptions { + json?: boolean; +} +export async function me(options: MeOptions): Promise<void> { const token = await requireToken(); - const json = await useJson(parsed); + const json = await useJson(options.json); const query = ` query { diff --git a/src/errors.ts b/src/errors.ts index 48b79c1..8a6e655 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,3 +1,5 @@ +import pc from "picocolors"; + export class CliError extends Error { readonly suggestion?: string; @@ -26,9 +28,9 @@ export function toCliError(error: unknown): CliError { export function printCliError(error: unknown): void { const cliError = toCliError(error); - console.error(`Error: ${cliError.message}`); + console.error(pc.red(`Error: ${cliError.message}`)); if (cliError.suggestion) { - console.error(`Hint: ${cliError.suggestion}`); + console.error(pc.yellow(`Hint: ${cliError.suggestion}`)); } } diff --git a/src/index.ts b/src/index.ts index 363215f..e6a7718 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,6 @@ #!/usr/bin/env bun +import { Command, InvalidArgumentError, Option } from "commander"; +import ora from "ora"; import { getAuthStatus, login, @@ -7,319 +9,296 @@ import { promptForTokenKind, readTokenFromStdin, } from "./auth"; +import { printCompletion } from "./cli"; import { - type CliOptions, - parseArgs, - printCompletion, - printHelp, - printVersion, -} from "./cli"; -import { + type CreateCommentOptions, createComment, + type DeleteCommentOptions, deleteComment, + type ListCommentsOptions, listComments, + type UpdateCommentOptions, updateComment, } from "./commands/comment"; -import { getCycle, listCycles } from "./commands/cycle"; import { + type GetCycleOptions, + getCycle, + type ListCyclesOptions, + listCycles, +} from "./commands/cycle"; +import { + type CreateDocumentOptions, createDocument, + type DeleteDocumentOptions, deleteDocument, + type GetDocumentOptions, getDocument, + type ListDocumentsOptions, listDocuments, + type UpdateDocumentOptions, updateDocument, } from "./commands/document"; -import { runGraphql } from "./commands/graphql"; -import { getInitiative, listInitiatives } from "./commands/initiative"; +import { type RunGraphqlOptions, runGraphql } from "./commands/graphql"; +import { + type GetInitiativeOptions, + getInitiative, + type ListInitiativesOptions, + listInitiatives, +} from "./commands/initiative"; import { + type CloseIssueOptions, + type CreateIssueOptions, closeIssue, createIssue, + type GetIssueOptions, getIssue, + type ListIssuesOptions, listIssues, + type UpdateIssueOptions, updateIssue, } from "./commands/issue"; import { + type CreateLabelOptions, createLabel, + type DeleteLabelOptions, deleteLabel, + type ListLabelsOptions, listLabels, + type UpdateLabelOptions, updateLabel, } from "./commands/label"; import { + type CreateMilestoneOptions, createMilestone, + type DeleteMilestoneOptions, deleteMilestone, + type GetMilestoneOptions, getMilestone, + type ListMilestonesOptions, listMilestones, + type UpdateMilestoneOptions, updateMilestone, } from "./commands/milestone"; -import { getProject, listProjects } from "./commands/project"; import { + type GetProjectOptions, + getProject, + type ListProjectsOptions, + listProjects, +} from "./commands/project"; +import { + type SearchDocumentsOptions, + type SearchIssuesOptions, + type SearchProjectsOptions, searchDocuments, searchIssues, searchProjects, } from "./commands/search"; import { getWorkspaceContext, + printTable, resetWorkspaceContext, setWorkspaceContext, } from "./commands/shared"; -import { listStates } from "./commands/state"; -import { getTeam, listTeams } from "./commands/team"; -import { getUser, listUsers, me } from "./commands/user"; +import { type ListStatesOptions, listStates } from "./commands/state"; +import { + type GetTeamOptions, + getTeam, + type ListTeamsOptions, + listTeams, +} from "./commands/team"; +import { + type GetUserOptions, + getUser, + type ListUsersOptions, + listUsers, + type MeOptions, + me, +} from "./commands/user"; import { getConfigPath, loadConfig, migrateConfigOnStartup, setDefaultWorkspace, } from "./config"; +import { APP_NAME, VERSION } from "./constants"; import { printCliError } from "./errors"; -async function main(): Promise<void> { - await migrateConfigOnStartup(); +function parsePositiveInt(name: string): (value: string) => number { + return (value: string): number => { + const n = Number.parseInt(value, 10); + if (!Number.isInteger(n) || n < 1) { + throw new InvalidArgumentError(`${name} must be a positive integer.`); + } + return n; + }; +} - const args = process.argv.slice(2); - const options = parseArgs(args); +function parseNonNegativeInt(name: string): (value: string) => number { + return (value: string): number => { + const n = Number.parseInt(value, 10); + if (!Number.isInteger(n) || n < 0) { + throw new InvalidArgumentError(`${name} must be a non-negative integer.`); + } + return n; + }; +} - if (options.version) { - printVersion(); - process.exit(0); +function parsePriority(value: string): number { + const n = Number.parseInt(value, 10); + if (!Number.isInteger(n) || n < 0 || n > 4) { + throw new InvalidArgumentError("priority must be an integer from 0 to 4."); } + return n; +} - if (options.completion) { - const success = printCompletion(options.completion); - process.exit(success ? 0 : 1); - } +function strict(command: Command): Command { + return command.allowUnknownOption(false).allowExcessArguments(false); +} - // Show global help only if no command specified - if (!options.command) { - printHelp(); - process.exit(options.help ? 0 : 1); - } +function addWorkspaceOption(command: Command): Command { + return command.option( + "-w, --workspace <name>", + "Use a specific workspace profile", + ); +} - setWorkspaceContext(options.workspace ?? undefined); - - // Pass help flag to subcommand handlers - const subcommandArgs = options.help - ? ["--help", ...options.args] - : options.args; - - try { - // Route to command handlers - switch (options.command) { - case "auth": - await handleAuth(options.subcommand, subcommandArgs); - break; - case "issue": - await handleIssue(options.subcommand, subcommandArgs); - break; - case "team": - await handleTeam(options.subcommand, subcommandArgs); - break; - case "project": - await handleProject(options.subcommand, subcommandArgs); - break; - case "cycle": - await handleCycle(options.subcommand, subcommandArgs); - break; - case "comment": - await handleComment(options.subcommand, subcommandArgs); - break; - case "document": - await handleDocument(options.subcommand, subcommandArgs); - break; - case "label": - await handleLabel(options.subcommand, subcommandArgs); - break; - case "milestone": - await handleMilestone(options.subcommand, subcommandArgs); - break; - case "initiative": - await handleInitiative(options.subcommand, subcommandArgs); - break; - case "user": - await handleUser(options.subcommand, subcommandArgs); - break; - case "state": - await handleState(options.subcommand, subcommandArgs); - break; - case "search": - await handleSearch(options.subcommand, subcommandArgs); - break; - case "graphql": - await handleGraphql(getGraphqlArgs(options)); - break; - default: - console.error(`Unknown command: ${options.command}`); - printHelp(); - process.exit(1); +function detectCompletionShell(args: string[]): string | null { + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (!arg) continue; + if (arg === "--completion") { + return args[i + 1] ?? null; + } + if (arg.startsWith("--completion=")) { + return arg.slice("--completion=".length); } - } finally { - resetWorkspaceContext(); } + return null; } -function parseAuthArgs(args: string[]): { - token?: string; - type?: "api" | "oauth"; - refreshToken?: string; - expiresAt?: string; - workspace?: string; - positionals: string[]; - help: boolean; -} { - let token: string | undefined; - let type: "api" | "oauth" | undefined; - let refreshToken: string | undefined; - let expiresAt: string | undefined; - let workspace: string | undefined; - let help = false; - const positionals: string[] = []; +async function run(): Promise<void> { + await migrateConfigOnStartup(); - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - if (arg === "-h" || arg === "--help") { - help = true; - } else if (arg === "--token" && args[i + 1]) { - token = args[i + 1]; - i++; - } else if (arg?.startsWith("--token=")) { - token = arg.slice(8); - } else if (arg === "--type" && args[i + 1]) { - const value = args[i + 1]; - if (value === "api" || value === "oauth") { - type = value; - } - i++; - } else if (arg?.startsWith("--type=")) { - const value = arg.slice(7); - if (value === "api" || value === "oauth") { - type = value; - } - } else if (arg === "--refresh-token" && args[i + 1]) { - refreshToken = args[i + 1]; - i++; - } else if (arg?.startsWith("--refresh-token=")) { - refreshToken = arg.slice(16); - } else if (arg === "--expires-at" && args[i + 1]) { - expiresAt = args[i + 1]; - i++; - } else if (arg?.startsWith("--expires-at=")) { - expiresAt = arg.slice(13); - } else if (arg === "-w" || arg === "--workspace") { - workspace = args[i + 1]; - i++; - } else if (arg?.startsWith("--workspace=")) { - workspace = arg.slice(12); - } else if (arg && !arg.startsWith("-")) { - positionals.push(arg); - } + const rawArgs = process.argv.slice(2); + const completionShell = detectCompletionShell(rawArgs); + if (completionShell) { + process.exit(printCompletion(completionShell) ? 0 : 1); } - return { token, type, refreshToken, expiresAt, workspace, positionals, help }; + const program = strict(new Command()); + program + .name(APP_NAME) + .description("Access Linear from your terminal via GraphQL API") + .version(VERSION, "-v, --version", "Show version") + .helpOption("-h, --help", "Show help") + .showHelpAfterError() + .option("--completion <shell>", "Generate shell completion") + .option("-w, --workspace <name>", "Use a specific workspace profile") + .hook("preAction", (_command, actionCommand) => { + const opts = actionCommand.optsWithGlobals<{ workspace?: string }>(); + setWorkspaceContext(opts.workspace); + }) + .hook("postAction", () => { + resetWorkspaceContext(); + }); + + registerAuth(program); + registerIssue(program); + registerTeam(program); + registerProject(program); + registerCycle(program); + registerComment(program); + registerDocument(program); + registerLabel(program); + registerMilestone(program); + registerInitiative(program); + registerUser(program); + registerState(program); + registerSearch(program); + registerGraphql(program); + + await program.parseAsync(process.argv); } -async function handleAuth( - subcommand: string | null, - args: string[], -): Promise<void> { - const parsed = parseAuthArgs(args); - const workspace = parsed.workspace ?? getWorkspaceContext(); - - switch (subcommand) { - case "login": { - if (parsed.help) { - console.log(` -Usage: linear auth login [--token <token>] [--type <api|oauth>] [--workspace <name>] - -Authenticate with Linear using an API token or an OAuth token. - -Options: - --token <token> Token value - --type <api|oauth> Required with --token or stdin - --refresh-token <token> OAuth refresh token (only with --type oauth) - --expires-at <iso> Access token expiry as ISO timestamp (only with --type oauth) - -w, --workspace <name> Save credentials to a specific workspace profile - -h, --help Show this help - -Interactive login will ask for the token type first. - -Get your API token from: - Linear Settings > API > Personal API keys -`); - return; - } +function registerAuth(program: Command): void { + const auth = strict(program.command("auth")).description( + "Manage authentication", + ); - let token = parsed.token; + strict(addWorkspaceOption(auth.command("login"))) + .description("Authenticate with API token or OAuth token") + .addOption( + new Option("--type <type>", "Token type").choices(["api", "oauth"]), + ) + .option("--token <token>", "Token value") + .option("--refresh-token <token>", "OAuth refresh token") + .option("--expires-at <iso>", "Access token expiry as ISO timestamp") + .action(async (options) => { + const workspace = options.workspace ?? getWorkspaceContext(); + let token: string | undefined = options.token; - if (token) { - if (!parsed.type) { - console.error("Error: --type <api|oauth> is required with --token"); - process.exit(1); - } - } else { - if (!process.stdin.isTTY && !parsed.type) { - console.error( - "Error: --type <api|oauth> is required when reading token from stdin", + if (token && !options.type) { + throw new Error("--type <api|oauth> is required with --token"); + } + + if (!token) { + if (!process.stdin.isTTY && !options.type) { + throw new Error( + "--type <api|oauth> is required when reading token from stdin", ); - process.exit(1); } - token = (await readTokenFromStdin()) ?? undefined; } - const kind = parsed.type ?? (await promptForTokenKind()); + const kind = + (options.type as "api" | "oauth" | undefined) ?? + (await promptForTokenKind()); if (!token) { token = await promptForToken(); } if (!token) { - console.error("Error: No token provided"); - process.exit(1); + throw new Error("No token provided"); } - console.log("Validating token..."); + const spinner = ora({ + text: "Validating token...", + isSilent: !process.stderr.isTTY, + }).start(); + const loginOptions = kind === "oauth" ? { - refreshToken: parsed.refreshToken, - expiresAt: parsed.expiresAt, + refreshToken: options.refreshToken as string | undefined, + expiresAt: options.expiresAt as string | undefined, workspace, } - : { - workspace, - }; + : { workspace }; + const result = await login(token, kind, loginOptions); - if (result.success) { - console.log( - `Authenticated as: ${result.name}${result.email ? ` (${result.email})` : ""}`, - ); - if (result.workspace) { - console.log( - `Workspace: ${result.orgName ?? result.workspace} (${result.workspace})`, - ); - } - console.log(`Token saved to: ${getConfigPath()}`); - } else { - console.error(`Login failed: ${result.error}`); - process.exit(1); + if (!result.success) { + spinner.fail("Login failed"); + throw new Error(result.error ?? "Unknown login error"); } - break; - } - - case "logout": { - if (parsed.help) { - console.log(` -Usage: linear auth logout [--workspace <name>] -Remove stored API token from config file. - -Options: - -w, --workspace <name> Workspace profile to remove - -h, --help Show this help -`); - return; + spinner.succeed("Authenticated"); + console.log( + `Authenticated as: ${result.name}${result.email ? ` (${result.email})` : ""}`, + ); + if (result.workspace) { + console.log( + `Workspace: ${result.orgName ?? result.workspace} (${result.workspace})`, + ); } + console.log(`Token saved to: ${getConfigPath()}`); + }); + strict(addWorkspaceOption(auth.command("logout"))) + .description("Remove stored credentials") + .action(async (options) => { + const workspace = options.workspace ?? getWorkspaceContext(); await logout(workspace); if (workspace) { console.log( @@ -328,31 +307,18 @@ Options: } else { console.log("Logged out. Token removed from config."); } - break; - } - - case "status": { - if (parsed.help) { - console.log(` -Usage: linear auth status [--workspace <name>] - -Show current authentication status. - -Options: - -w, --workspace <name> Workspace profile to inspect - -h, --help Show this help -`); - return; - } + }); + strict(addWorkspaceOption(auth.command("status"))) + .description("Show current authentication status") + .action(async (options) => { + const workspace = options.workspace ?? getWorkspaceContext(); const status = await getAuthStatus(workspace); if (status.authenticated) { console.log("Status: Authenticated"); console.log(`User: ${status.name}`); - if (status.email) { - console.log(`Email: ${status.email}`); - } + if (status.email) console.log(`Email: ${status.email}`); if (status.workspace) { console.log( `Workspace: ${status.orgName ?? status.workspace} (${status.workspace})`, @@ -371,24 +337,13 @@ Options: `Token source: ${status.tokenSource === "env" ? "environment variable" : "config file"}`, ); } - process.exit(1); - } - break; - } - - case "list": { - if (parsed.help) { - console.log(` -Usage: linear auth list - -List configured workspace profiles. - -Options: - -h, --help Show this help -`); - return; + process.exitCode = 1; } + }); + strict(auth.command("list")) + .description("List configured workspace profiles") + .action(async () => { const config = await loadConfig(); const entries = Object.entries(config.workspaces ?? {}); @@ -407,340 +362,673 @@ Options: return [name, profile.orgName ?? "-", type, marker]; }); - const headers = ["NAME", "ORG", "TYPE", "DEFAULT"]; - const widths = headers.map((header, index) => - Math.max( - header.length, - ...rows.map((row) => (row[index] ?? "").length), - ), - ); + printTable(["NAME", "ORG", "TYPE", "DEFAULT"], rows); + }); - console.log(headers.map((h, i) => h.padEnd(widths[i] ?? 0)).join(" ")); - for (const row of rows) { - console.log( - row.map((cell, i) => cell.padEnd(widths[i] ?? 0)).join(" "), - ); - } - break; - } + strict(auth.command("use")) + .description("Set default workspace profile") + .argument("<name>", "Workspace profile name") + .action(async (name: string) => { + await setDefaultWorkspace(name); + console.log(`Default workspace set to "${name}".`); + }); +} - case "use": { - if (parsed.help) { - console.log(` -Usage: linear auth use <name> +function registerIssue(program: Command): void { + const issue = strict(program.command("issue")).description( + "Issue operations", + ); -Set default workspace profile. + strict(addWorkspaceOption(issue.command("list"))) + .description("List issues") + .option("--team <key>", "Filter by team key") + .option("--assignee <value>", "Filter by assignee") + .option("--delegate <value>", "Filter by delegate/agent") + .option("--agent <value>", "Alias for --delegate") + .option("--state <name>", "Filter by state") + .option("--label <name>", "Filter by label") + .option("--limit <n>", "Max results", parsePositiveInt("limit")) + .option("--json", "Output as JSON") + .action(async (options) => { + const opts: ListIssuesOptions = { + team: options.team, + assignee: options.assignee, + delegate: options.delegate, + agent: options.agent, + state: options.state, + label: options.label, + limit: options.limit, + json: options.json, + }; + await listIssues(opts); + }); -Options: - -h, --help Show this help -`); - return; - } + strict(addWorkspaceOption(issue.command("get"))) + .description("Get issue by identifier") + .argument("<identifier>", "Issue identifier or UUID") + .option("--json", "Output as JSON") + .action(async (identifier: string, options) => { + const opts: GetIssueOptions = { + identifier, + json: options.json, + }; + await getIssue(opts); + }); - const name = parsed.positionals[0]; - if (!name) { - console.error( - "Error: Missing workspace name. Usage: linear auth use <name>", - ); - process.exit(1); - } + strict(addWorkspaceOption(issue.command("create"))) + .description("Create an issue") + .requiredOption("--team <key>", "Team key") + .requiredOption("--title <title>", "Issue title") + .option("--description <text>", "Issue description") + .option("--priority <0-4>", "Issue priority", parsePriority) + .option("--assignee <value>", "Assignee") + .option("--delegate <value>", "Delegate/agent") + .option("--agent <value>", "Alias for --delegate") + .option("--state <id>", "State ID") + .option("--label <ids>", "Comma-separated label IDs") + .option("--project <id>", "Project ID") + .option("--estimate <n>", "Estimate", parseNonNegativeInt("estimate")) + .option("--json", "Output as JSON") + .action(async (options) => { + const opts: CreateIssueOptions = { + team: options.team, + title: options.title, + description: options.description, + priority: options.priority, + assignee: options.assignee, + delegate: options.delegate, + agent: options.agent, + state: options.state, + label: options.label, + project: options.project, + estimate: options.estimate, + json: options.json, + }; + await createIssue(opts); + }); - await setDefaultWorkspace(name); - console.log(`Default workspace set to "${name}".`); - break; - } + strict(addWorkspaceOption(issue.command("update"))) + .description("Update an issue") + .argument("<identifier>", "Issue identifier or UUID") + .option("--title <title>", "New title") + .option("--description <text>", "New description") + .option("--priority <0-4>", "New priority", parsePriority) + .option("--state <id>", "New state ID") + .option("--assignee <value>", "New assignee") + .option("--delegate <value>", "New delegate/agent") + .option("--agent <value>", "Alias for --delegate") + .option("--label <ids>", "New comma-separated label IDs") + .option("--project <id>", "New project ID") + .option("--estimate <n>", "New estimate", parseNonNegativeInt("estimate")) + .option("--json", "Output as JSON") + .action(async (identifier: string, options) => { + const opts: UpdateIssueOptions = { + identifier, + title: options.title, + description: options.description, + priority: options.priority, + state: options.state, + assignee: options.assignee, + delegate: options.delegate, + agent: options.agent, + label: options.label, + project: options.project, + estimate: options.estimate, + json: options.json, + }; + await updateIssue(opts); + }); - default: - console.error( - `Usage: linear auth <login|logout|status|list|use>\n\nSubcommands:\n login Authenticate with API token\n logout Remove stored credentials\n status Show authentication status\n list List workspace profiles\n use Set default workspace`, - ); - process.exit(1); - } + strict(addWorkspaceOption(issue.command("close"))) + .description("Close an issue") + .argument("<identifier>", "Issue identifier or UUID") + .option("--json", "Output as JSON") + .action(async (identifier: string, options) => { + const opts: CloseIssueOptions = { + identifier, + json: options.json, + }; + await closeIssue(opts); + }); } -async function handleIssue( - subcommand: string | null, - args: string[], -): Promise<void> { - switch (subcommand) { - case "list": - await listIssues(args); - break; - case "get": - await getIssue(args); - break; - case "create": - await createIssue(args); - break; - case "update": - await updateIssue(args); - break; - case "close": - await closeIssue(args); - break; - default: - console.error( - `Usage: linear issue <list|get|create|update|close>\n\nSubcommands:\n list List issues\n get Get issue by identifier\n create Create an issue\n update Update an issue\n close Close an issue`, - ); - process.exit(1); - } -} +function registerTeam(program: Command): void { + const team = strict(program.command("team")).description("Team operations"); -async function handleTeam( - subcommand: string | null, - args: string[], -): Promise<void> { - switch (subcommand) { - case "list": - await listTeams(args); - break; - case "get": - await getTeam(args); - break; - default: - console.error( - `Usage: linear team <list|get>\n\nSubcommands:\n list List teams\n get Get team by key`, - ); - process.exit(1); - } -} + strict(addWorkspaceOption(team.command("list"))) + .description("List teams") + .option("--limit <n>", "Maximum results", parsePositiveInt("limit")) + .option("--json", "Output as JSON") + .action(async (options) => { + const opts: ListTeamsOptions = { + limit: options.limit, + json: options.json, + }; + await listTeams(opts); + }); -async function handleProject( - subcommand: string | null, - args: string[], -): Promise<void> { - switch (subcommand) { - case "list": - await listProjects(args); - break; - case "get": - await getProject(args); - break; - default: - console.error( - `Usage: linear project <list|get>\n\nSubcommands:\n list List projects\n get Get project by ID`, - ); - process.exit(1); - } + strict(addWorkspaceOption(team.command("get"))) + .description("Get team by key or ID") + .argument("<team-key-or-id>") + .option("--json", "Output as JSON") + .action(async (teamRef: string, options) => { + const opts: GetTeamOptions = { + teamRef, + json: options.json, + }; + await getTeam(opts); + }); } -async function handleCycle( - subcommand: string | null, - args: string[], -): Promise<void> { - switch (subcommand) { - case "list": - await listCycles(args); - break; - case "get": - await getCycle(args); - break; - default: - console.error( - `Usage: linear cycle <list|get>\n\nSubcommands:\n list List cycles\n get Get cycle by ID`, - ); - process.exit(1); - } +function registerProject(program: Command): void { + const project = strict(program.command("project")).description( + "Project operations", + ); + + strict(addWorkspaceOption(project.command("list"))) + .description("List projects") + .option("--limit <n>", "Maximum results", parsePositiveInt("limit")) + .addOption( + new Option("--status <status>", "Project status").choices([ + "planned", + "started", + "paused", + "completed", + "canceled", + ]), + ) + .option("--json", "Output as JSON") + .action(async (options) => { + const opts: ListProjectsOptions = { + limit: options.limit, + status: options.status, + json: options.json, + }; + await listProjects(opts); + }); + + strict(addWorkspaceOption(project.command("get"))) + .description("Get project by ID") + .argument("<id>") + .option("--json", "Output as JSON") + .action(async (id: string, options) => { + const opts: GetProjectOptions = { + id, + json: options.json, + }; + await getProject(opts); + }); } -async function handleComment( - subcommand: string | null, - args: string[], -): Promise<void> { - switch (subcommand) { - case "list": - await listComments(args); - break; - case "create": - await createComment(args); - break; - case "update": - await updateComment(args); - break; - case "delete": - await deleteComment(args); - break; - default: - console.error( - `Usage: linear comment <list|create|update|delete>\n\nSubcommands:\n list List comments on an issue\n create Add a comment to an issue\n update Edit a comment\n delete Delete a comment`, - ); - process.exit(1); - } +function registerCycle(program: Command): void { + const cycle = strict(program.command("cycle")).description( + "Cycle operations", + ); + + strict(addWorkspaceOption(cycle.command("list"))) + .description("List cycles") + .option("--team <key>", "Filter by team key") + .option("--limit <n>", "Maximum results", parsePositiveInt("limit")) + .option("--json", "Output as JSON") + .action(async (options) => { + const opts: ListCyclesOptions = { + team: options.team, + limit: options.limit, + json: options.json, + }; + await listCycles(opts); + }); + + strict(addWorkspaceOption(cycle.command("get"))) + .description("Get cycle by ID") + .argument("<id>") + .option("--json", "Output as JSON") + .action(async (id: string, options) => { + const opts: GetCycleOptions = { + id, + json: options.json, + }; + await getCycle(opts); + }); } -async function handleDocument( - subcommand: string | null, - args: string[], -): Promise<void> { - switch (subcommand) { - case "list": - await listDocuments(args); - break; - case "get": - await getDocument(args); - break; - case "create": - await createDocument(args); - break; - case "update": - await updateDocument(args); - break; - case "delete": - await deleteDocument(args); - break; - default: - console.error( - `Usage: linear document <list|get|create|update|delete>\n\nSubcommands:\n list List documents\n get Get document by ID\n create Create a document\n update Update a document\n delete Delete a document`, - ); - process.exit(1); - } +function registerComment(program: Command): void { + const comment = strict(program.command("comment")).description( + "Comment operations", + ); + + strict(addWorkspaceOption(comment.command("list"))) + .description("List comments") + .requiredOption("--issue <identifier>", "Issue identifier") + .option("--json", "Output as JSON") + .action(async (options) => { + const opts: ListCommentsOptions = { + issue: options.issue, + json: options.json, + }; + await listComments(opts); + }); + + strict(addWorkspaceOption(comment.command("create"))) + .description("Create comment") + .requiredOption("--issue <identifier>", "Issue identifier") + .requiredOption("--body <text>", "Comment body") + .option("--json", "Output as JSON") + .action(async (options) => { + const opts: CreateCommentOptions = { + issue: options.issue, + body: options.body, + json: options.json, + }; + await createComment(opts); + }); + + strict(addWorkspaceOption(comment.command("update"))) + .description("Update comment") + .argument("<id>") + .requiredOption("--body <text>", "Updated comment body") + .option("--json", "Output as JSON") + .action(async (id: string, options) => { + const opts: UpdateCommentOptions = { + id, + body: options.body, + json: options.json, + }; + await updateComment(opts); + }); + + strict(addWorkspaceOption(comment.command("delete"))) + .description("Delete comment") + .argument("<id>") + .option("--json", "Output as JSON") + .action(async (id: string, options) => { + const opts: DeleteCommentOptions = { + id, + json: options.json, + }; + await deleteComment(opts); + }); } -async function handleLabel( - subcommand: string | null, - args: string[], -): Promise<void> { - switch (subcommand) { - case "list": - await listLabels(args); - break; - case "create": - await createLabel(args); - break; - case "update": - await updateLabel(args); - break; - case "delete": - await deleteLabel(args); - break; - default: - console.error( - `Usage: linear label <list|create|update|delete>\n\nSubcommands:\n list List labels\n create Create a label\n update Update a label\n delete Delete a label`, - ); - process.exit(1); - } +function registerDocument(program: Command): void { + const document = strict(program.command("document")).description( + "Document operations", + ); + + strict(addWorkspaceOption(document.command("list"))) + .description("List documents") + .option("--project <id>", "Filter by project ID") + .option("--limit <n>", "Maximum results", parsePositiveInt("limit")) + .option("--json", "Output as JSON") + .action(async (options) => { + const opts: ListDocumentsOptions = { + project: options.project, + limit: options.limit, + json: options.json, + }; + await listDocuments(opts); + }); + + strict(addWorkspaceOption(document.command("get"))) + .description("Get document by ID") + .argument("<id>") + .option("--json", "Output as JSON") + .action(async (id: string, options) => { + const opts: GetDocumentOptions = { + id, + json: options.json, + }; + await getDocument(opts); + }); + + strict(addWorkspaceOption(document.command("create"))) + .description("Create document") + .requiredOption("--title <title>", "Document title") + .requiredOption("--content <markdown>", "Document content markdown") + .option("--project <id>", "Project ID") + .option("--json", "Output as JSON") + .action(async (options) => { + const opts: CreateDocumentOptions = { + title: options.title, + content: options.content, + project: options.project, + json: options.json, + }; + await createDocument(opts); + }); + + strict(addWorkspaceOption(document.command("update"))) + .description("Update document") + .argument("<id>") + .option("--title <title>", "New title") + .option("--content <content>", "New content") + .option("--json", "Output as JSON") + .action(async (id: string, options) => { + const opts: UpdateDocumentOptions = { + id, + title: options.title, + content: options.content, + json: options.json, + }; + await updateDocument(opts); + }); + + strict(addWorkspaceOption(document.command("delete"))) + .description("Delete document") + .argument("<id>") + .option("--json", "Output as JSON") + .action(async (id: string, options) => { + const opts: DeleteDocumentOptions = { + id, + json: options.json, + }; + await deleteDocument(opts); + }); } -async function handleMilestone( - subcommand: string | null, - args: string[], -): Promise<void> { - switch (subcommand) { - case "list": - await listMilestones(args); - break; - case "get": - await getMilestone(args); - break; - case "create": - await createMilestone(args); - break; - case "update": - await updateMilestone(args); - break; - case "delete": - await deleteMilestone(args); - break; - default: - console.error( - `Usage: linear milestone <list|get|create|update|delete>\n\nSubcommands:\n list List milestones\n get Get milestone by ID\n create Create a milestone\n update Update a milestone\n delete Delete a milestone`, - ); - process.exit(1); - } +function registerLabel(program: Command): void { + const label = strict(program.command("label")).description( + "Label operations", + ); + + strict(addWorkspaceOption(label.command("list"))) + .description("List labels") + .option("--team <key>", "Filter by team key") + .option("--limit <n>", "Maximum results", parsePositiveInt("limit")) + .option("--json", "Output as JSON") + .action(async (options) => { + const opts: ListLabelsOptions = { + team: options.team, + limit: options.limit, + json: options.json, + }; + await listLabels(opts); + }); + + strict(addWorkspaceOption(label.command("create"))) + .description("Create label") + .requiredOption("--name <name>", "Label name") + .requiredOption("--color <hex>", "Label color") + .option("--team <key>", "Team key") + .option("--description <text>", "Description") + .option("--json", "Output as JSON") + .action(async (options) => { + const opts: CreateLabelOptions = { + name: options.name, + color: options.color, + team: options.team, + description: options.description, + json: options.json, + }; + await createLabel(opts); + }); + + strict(addWorkspaceOption(label.command("update"))) + .description("Update label") + .argument("<id>") + .option("--name <name>", "New name") + .option("--color <hex>", "New color") + .option("--description <text>", "New description") + .option("--json", "Output as JSON") + .action(async (id: string, options) => { + const opts: UpdateLabelOptions = { + id, + name: options.name, + color: options.color, + description: options.description, + json: options.json, + }; + await updateLabel(opts); + }); + + strict(addWorkspaceOption(label.command("delete"))) + .description("Delete label") + .argument("<id>") + .option("--json", "Output as JSON") + .action(async (id: string, options) => { + const opts: DeleteLabelOptions = { + id, + json: options.json, + }; + await deleteLabel(opts); + }); } -async function handleInitiative( - subcommand: string | null, - args: string[], -): Promise<void> { - switch (subcommand) { - case "list": - await listInitiatives(args); - break; - case "get": - await getInitiative(args); - break; - default: - console.error( - `Usage: linear initiative <list|get>\n\nSubcommands:\n list List initiatives\n get Get initiative by ID`, - ); - process.exit(1); - } +function registerMilestone(program: Command): void { + const milestone = strict(program.command("milestone")).description( + "Milestone operations", + ); + + strict(addWorkspaceOption(milestone.command("list"))) + .description("List milestones") + .option("--project <id>", "Filter by project ID") + .option("--limit <n>", "Maximum results", parsePositiveInt("limit")) + .option("--json", "Output as JSON") + .action(async (options) => { + const opts: ListMilestonesOptions = { + project: options.project, + limit: options.limit, + json: options.json, + }; + await listMilestones(opts); + }); + + strict(addWorkspaceOption(milestone.command("get"))) + .description("Get milestone") + .argument("<id>") + .option("--json", "Output as JSON") + .action(async (id: string, options) => { + const opts: GetMilestoneOptions = { + id, + json: options.json, + }; + await getMilestone(opts); + }); + + strict(addWorkspaceOption(milestone.command("create"))) + .description("Create milestone") + .requiredOption("--project <id>", "Project ID") + .requiredOption("--name <name>", "Milestone name") + .option("--description <text>", "Description") + .option("--targetDate <date>", "Target date (YYYY-MM-DD)") + .option("--json", "Output as JSON") + .action(async (options) => { + const opts: CreateMilestoneOptions = { + project: options.project, + name: options.name, + description: options.description, + targetDate: options.targetDate, + json: options.json, + }; + await createMilestone(opts); + }); + + strict(addWorkspaceOption(milestone.command("update"))) + .description("Update milestone") + .argument("<id>") + .option("--name <name>", "New name") + .option("--description <text>", "New description") + .option("--targetDate <date>", "New target date") + .option("--json", "Output as JSON") + .action(async (id: string, options) => { + const opts: UpdateMilestoneOptions = { + id, + name: options.name, + description: options.description, + targetDate: options.targetDate, + json: options.json, + }; + await updateMilestone(opts); + }); + + strict(addWorkspaceOption(milestone.command("delete"))) + .description("Delete milestone") + .argument("<id>") + .option("--json", "Output as JSON") + .action(async (id: string, options) => { + const opts: DeleteMilestoneOptions = { + id, + json: options.json, + }; + await deleteMilestone(opts); + }); } -async function handleUser( - subcommand: string | null, - args: string[], -): Promise<void> { - switch (subcommand) { - case "list": - await listUsers(args); - break; - case "get": - await getUser(args); - break; - case "me": - await me(args); - break; - default: - console.error( - `Usage: linear user <list|get|me>\n\nSubcommands:\n list List users\n get Get user by ID\n me Get authenticated user`, - ); - process.exit(1); - } +function registerInitiative(program: Command): void { + const initiative = strict(program.command("initiative")).description( + "Initiative operations", + ); + + strict(addWorkspaceOption(initiative.command("list"))) + .description("List initiatives") + .option("--limit <n>", "Maximum results", parsePositiveInt("limit")) + .option("--json", "Output as JSON") + .action(async (options) => { + const opts: ListInitiativesOptions = { + limit: options.limit, + json: options.json, + }; + await listInitiatives(opts); + }); + + strict(addWorkspaceOption(initiative.command("get"))) + .description("Get initiative") + .argument("<id>") + .option("--json", "Output as JSON") + .action(async (id: string, options) => { + const opts: GetInitiativeOptions = { + id, + json: options.json, + }; + await getInitiative(opts); + }); } -async function handleState( - subcommand: string | null, - args: string[], -): Promise<void> { - switch (subcommand) { - case "list": - await listStates(args); - break; - default: - console.error( - `Usage: linear state <list>\n\nSubcommands:\n list List workflow states`, - ); - process.exit(1); - } +function registerUser(program: Command): void { + const user = strict(program.command("user")).description("User operations"); + + strict(addWorkspaceOption(user.command("list"))) + .description("List users") + .option("--limit <n>", "Maximum results", parsePositiveInt("limit")) + .option("--json", "Output as JSON") + .action(async (options) => { + const opts: ListUsersOptions = { + limit: options.limit, + json: options.json, + }; + await listUsers(opts); + }); + + strict(addWorkspaceOption(user.command("get"))) + .description("Get user") + .argument("<id>") + .option("--json", "Output as JSON") + .action(async (id: string, options) => { + const opts: GetUserOptions = { + id, + json: options.json, + }; + await getUser(opts); + }); + + strict(addWorkspaceOption(user.command("me"))) + .description("Get authenticated user") + .option("--json", "Output as JSON") + .action(async (options) => { + const opts: MeOptions = { + json: options.json, + }; + await me(opts); + }); } -async function handleSearch( - subcommand: string | null, - args: string[], -): Promise<void> { - switch (subcommand) { - case "issues": - await searchIssues(args); - break; - case "documents": - await searchDocuments(args); - break; - case "projects": - await searchProjects(args); - break; - default: - console.error( - `Usage: linear search <issues|documents|projects>\n\nSubcommands:\n issues Search issues by text\n documents Search documents by text\n projects Search projects by text`, - ); - process.exit(1); - } +function registerState(program: Command): void { + const state = strict(program.command("state")).description( + "Workflow state operations", + ); + + strict(addWorkspaceOption(state.command("list"))) + .description("List workflow states") + .option("--team <key>", "Filter by team key") + .option("--limit <n>", "Maximum results", parsePositiveInt("limit")) + .option("--json", "Output as JSON") + .action(async (options) => { + const opts: ListStatesOptions = { + team: options.team, + limit: options.limit, + json: options.json, + }; + await listStates(opts); + }); } -async function handleGraphql(args: string[]): Promise<void> { - await runGraphql(args); +function registerSearch(program: Command): void { + const search = strict(program.command("search")).description("Search"); + + strict(addWorkspaceOption(search.command("issues"))) + .description("Search issues") + .argument("<term>") + .option("--team <key>", "Filter by team") + .option("--limit <n>", "Maximum results", parsePositiveInt("limit")) + .option("--json", "Output as JSON") + .action(async (term: string, options) => { + const opts: SearchIssuesOptions = { + term, + team: options.team, + limit: options.limit, + json: options.json, + }; + await searchIssues(opts); + }); + + strict(addWorkspaceOption(search.command("documents"))) + .description("Search documents") + .argument("<term>") + .option("--limit <n>", "Maximum results", parsePositiveInt("limit")) + .option("--json", "Output as JSON") + .action(async (term: string, options) => { + const opts: SearchDocumentsOptions = { + term, + limit: options.limit, + json: options.json, + }; + await searchDocuments(opts); + }); + + strict(addWorkspaceOption(search.command("projects"))) + .description("Search projects") + .argument("<term>") + .option("--limit <n>", "Maximum results", parsePositiveInt("limit")) + .option("--json", "Output as JSON") + .action(async (term: string, options) => { + const opts: SearchProjectsOptions = { + term, + limit: options.limit, + json: options.json, + }; + await searchProjects(opts); + }); } -function getGraphqlArgs(options: CliOptions): string[] { - return [ - ...(options.help ? ["--help"] : []), - ...(options.subcommand ? [options.subcommand] : []), - ...options.args, - ]; +function registerGraphql(program: Command): void { + strict(addWorkspaceOption(program.command("graphql"))) + .description("Run arbitrary GraphQL") + .argument("[query...]", "GraphQL query/mutation string") + .option("--variables <json>", "Variables object as JSON") + .action(async (queryParts: string[] | undefined, options) => { + const query = + Array.isArray(queryParts) && queryParts.length > 0 + ? queryParts.join(" ") + : undefined; + const opts: RunGraphqlOptions = { + query, + variables: options.variables, + }; + await runGraphql(opts); + }); } -main().catch((err) => { +run().catch((err) => { printCliError(err); process.exit(1); });