|
| 1 | +--- |
| 2 | +name: new-command |
| 3 | +description: >- |
| 4 | + This skill should be used when the user asks to "build a new command", |
| 5 | + "create a command", "implement a command", "add a new CLI command", |
| 6 | + or needs to build a new command for CLI for Microsoft 365 from a |
| 7 | + GitHub issue spec. It covers the full workflow: command logic, |
| 8 | + unit tests, documentation, sidebar registration, and PR checklist |
| 9 | + verification. |
| 10 | +--- |
| 11 | + |
| 12 | +# Building a New Command for CLI for Microsoft 365 |
| 13 | + |
| 14 | +Build a complete, production-ready CLI command from a GitHub issue spec. The workflow produces four artifacts: command implementation, unit tests, documentation page, and sidebar registration — then verifies everything against the PR checklist. |
| 15 | + |
| 16 | +## Prerequisites |
| 17 | + |
| 18 | +A GitHub issue containing the command spec (name, description, options, examples, API details). If no issue is provided, **STOP — ask the user for the issue URL or spec before proceeding.** |
| 19 | + |
| 20 | +## Workflow |
| 21 | + |
| 22 | +Execute each phase in order. Do not skip phases. |
| 23 | + |
| 24 | +### Phase 1: Parse the Spec |
| 25 | + |
| 26 | +1. Read the GitHub issue thoroughly. |
| 27 | +2. Extract: command name, description, service/workload, options (required/optional, types, aliases, allowed values, option sets), API endpoints used, example usage, and expected response shape. |
| 28 | +3. **STOP — Verify API details are complete.** The spec must include the full API endpoint(s), HTTP method(s), request payloads, and response shapes. If any of these are missing, **ask the user** to provide them or point to API documentation. **NEVER fabricate or infer API request/response shapes** — even if similar commands exist in the codebase. |
| 29 | +4. Identify the base class. Look at existing commands in `src/m365/<service>/commands/` to determine which base class to extend (`SpoCommand`, `GraphCommand`, `GraphApplicationCommand`, `AzmgmtCommand`, etc.). |
| 30 | +5. Check that every word in the command name exists in the dictionary in `eslint.config.mjs`. If a word is missing, add it to the `dictionary` array (keep alphabetical order). |
| 31 | + |
| 32 | +### Phase 2: Implement the Command |
| 33 | + |
| 34 | +Create `src/m365/<service>/commands/<noun>/<noun>-<verb>.ts`. |
| 35 | + |
| 36 | +#### Structure |
| 37 | + |
| 38 | +```typescript |
| 39 | +import { globalOptionsZod } from '../../../../Command.js'; |
| 40 | +import { z } from 'zod'; |
| 41 | +import { Logger } from '../../../../cli/Logger.js'; |
| 42 | +import commands from '../../commands.js'; |
| 43 | +import <BaseCommand> from '../../../base/<BaseCommand>.js'; |
| 44 | +import request, { CliRequestOptions } from '../../../../request.js'; |
| 45 | +// additional imports as needed |
| 46 | + |
| 47 | +// Enums for options with predefined values |
| 48 | +// enum Foo { Bar = 'bar', Baz = 'baz' } |
| 49 | + |
| 50 | +export const options = z.strictObject({ |
| 51 | + ...globalOptionsZod.shape, |
| 52 | + // command-specific options |
| 53 | +}); |
| 54 | +declare type Options = z.infer<typeof options>; |
| 55 | + |
| 56 | +interface CommandArgs { |
| 57 | + options: Options; |
| 58 | +} |
| 59 | + |
| 60 | +class <Service><Noun><Verb>Command extends <BaseCommand> { |
| 61 | + public get name(): string { |
| 62 | + return commands.<NOUN>_<VERB>; |
| 63 | + } |
| 64 | + |
| 65 | + public get description(): string { |
| 66 | + return '<description from spec>'; |
| 67 | + } |
| 68 | + |
| 69 | + public get schema(): z.ZodType { |
| 70 | + return options; |
| 71 | + } |
| 72 | + |
| 73 | + // getRefinedSchema — only if option sets or cross-field validation needed |
| 74 | + |
| 75 | + public async commandAction(logger: Logger, args: CommandArgs): Promise<void> { |
| 76 | + try { |
| 77 | + if (this.verbose) { |
| 78 | + await logger.logToStderr(`<Verbose message>...`); |
| 79 | + } |
| 80 | + |
| 81 | + const requestOptions: CliRequestOptions = { |
| 82 | + url: `<endpoint>`, |
| 83 | + headers: { accept: 'application/json;odata.metadata=none' }, |
| 84 | + responseType: 'json' |
| 85 | + }; |
| 86 | + |
| 87 | + const result = await request.get<any>(requestOptions); |
| 88 | + await logger.log(result); |
| 89 | + } |
| 90 | + catch (err: any) { |
| 91 | + this.handleRejectedODataJsonPromise(err); |
| 92 | + } |
| 93 | + } |
| 94 | +} |
| 95 | + |
| 96 | +export default new <Service><Noun><Verb>Command(); |
| 97 | +``` |
| 98 | + |
| 99 | +#### Key rules |
| 100 | + |
| 101 | +- Class name: `<Service><Noun><Verb>Command` in PascalCase. |
| 102 | +- Options: use `z.strictObject` spreading `globalOptionsZod.shape`. |
| 103 | +- Aliases: `.alias('x')` on the Zod property. |
| 104 | +- Enums: `zod.coercedEnum(MyEnum)` for case-insensitive matching. Import `{ zod }` from `../../../../utils/zod.js`. |
| 105 | +- Validation: Zod refinements on properties (`.refine()`), not custom validate methods. |
| 106 | +- URL validation for SharePoint: `.refine(url => validation.isValidSharePointUrl(url) === true, { error: '...' })`. |
| 107 | +- Option sets: implement `getRefinedSchema(schema)` returning `schema.refine(...)`. |
| 108 | +- Async/await only — no `.then()`. |
| 109 | +- Verbose/debug logging → `logger.logToStderr`. |
| 110 | +- Error handling → `this.handleRejectedODataJsonPromise(err)`. |
| 111 | +- SPO file/folder endpoints: use `GetFileByServerRelativePath` / `GetFolderByServerRelativePath`. |
| 112 | +- Remove commands: include a `force` option and confirmation prompt using `cli.handleMultipleResultsFound` or `cli.promptForConfirmation`. |
| 113 | +- No `any` types (except the catch clause). Use specific interfaces/types. |
| 114 | +- No commented-out code. |
| 115 | + |
| 116 | +#### Register the command name |
| 117 | + |
| 118 | +Add the command constant to `src/m365/<service>/commands.ts`, keeping groups alphabetically sorted: |
| 119 | + |
| 120 | +```typescript |
| 121 | +export default { |
| 122 | + // ...existing commands... |
| 123 | + <NOUN>_<VERB>: `${prefix} <noun> <verb>`, |
| 124 | + // ... |
| 125 | +}; |
| 126 | +``` |
| 127 | + |
| 128 | +### Phase 3: Write Unit Tests |
| 129 | + |
| 130 | +Create `src/m365/<service>/commands/<noun>/<noun>-<verb>.spec.ts`. |
| 131 | + |
| 132 | +#### Skeleton |
| 133 | + |
| 134 | +```typescript |
| 135 | +import assert from 'assert'; |
| 136 | +import sinon from 'sinon'; |
| 137 | +import auth from '../../../../Auth.js'; |
| 138 | +import { CommandError } from '../../../../Command.js'; |
| 139 | +import { cli } from '../../../../cli/cli.js'; |
| 140 | +import { CommandInfo } from '../../../../cli/CommandInfo.js'; |
| 141 | +import { Logger } from '../../../../cli/Logger.js'; |
| 142 | +import { telemetry } from '../../../../telemetry.js'; |
| 143 | +import { pid } from '../../../../utils/pid.js'; |
| 144 | +import { session } from '../../../../utils/session.js'; |
| 145 | +import { sinonUtil } from '../../../../utils/sinonUtil.js'; |
| 146 | +import request from '../../../../request.js'; |
| 147 | +import commands from '../../commands.js'; |
| 148 | +import command, { options as commandOptionsSchema } from './<noun>-<verb>.js'; |
| 149 | + |
| 150 | +describe(commands.<NOUN>_<VERB>, () => { |
| 151 | + let log: any[]; |
| 152 | + let logger: Logger; |
| 153 | + let loggerLogSpy: sinon.SinonSpy; |
| 154 | + let commandInfo: CommandInfo; |
| 155 | + |
| 156 | + before(() => { |
| 157 | + sinon.stub(auth, 'restoreAuth').resolves(); |
| 158 | + sinon.stub(telemetry, 'trackEvent').resolves(); |
| 159 | + sinon.stub(pid, 'getProcessName').returns(''); |
| 160 | + sinon.stub(session, 'getId').returns(''); |
| 161 | + auth.connection.active = true; |
| 162 | + commandInfo = cli.getCommandInfo(command); |
| 163 | + }); |
| 164 | + |
| 165 | + beforeEach(() => { |
| 166 | + log = []; |
| 167 | + logger = { |
| 168 | + log: async (msg: string) => { log.push(msg); }, |
| 169 | + logRaw: async (msg: string) => { log.push(msg); }, |
| 170 | + logToStderr: async (msg: string) => { log.push(msg); } |
| 171 | + }; |
| 172 | + loggerLogSpy = sinon.spy(logger, 'log'); |
| 173 | + }); |
| 174 | + |
| 175 | + afterEach(() => { |
| 176 | + sinonUtil.restore([ |
| 177 | + request.get, |
| 178 | + request.post, |
| 179 | + request.put, |
| 180 | + request.patch, |
| 181 | + request.delete |
| 182 | + // restore only the HTTP methods actually stubbed |
| 183 | + ]); |
| 184 | + }); |
| 185 | + |
| 186 | + after(() => { |
| 187 | + sinon.restore(); |
| 188 | + auth.connection.active = false; |
| 189 | + }); |
| 190 | + |
| 191 | + it('has the correct name', () => { |
| 192 | + assert.strictEqual(command.name, commands.<NOUN>_<VERB>); |
| 193 | + }); |
| 194 | + |
| 195 | + it('has a description', () => { |
| 196 | + assert.notStrictEqual(command.description, null); |
| 197 | + }); |
| 198 | + |
| 199 | + // Validation tests — one pass and one fail per validation rule |
| 200 | + // Option set tests — valid combos and invalid combos |
| 201 | + // commandAction tests — one per branch/code path |
| 202 | + // API error test |
| 203 | +}); |
| 204 | +``` |
| 205 | + |
| 206 | +#### Required test categories |
| 207 | + |
| 208 | +1. **Name and description** — always. |
| 209 | +2. **Validation** — each Zod refinement tested for pass and fail using `commandOptionsSchema.safeParse(...)`. |
| 210 | +3. **Option sets** — valid single option, invalid multiple options, missing required option. |
| 211 | +4. **Command action** — one test per logical branch. Stub `request.get`/`post`/etc. with `callsFake` matching URL patterns. |
| 212 | +5. **Error handling** — stub request to reject, assert `CommandError`. |
| 213 | +6. **Coverage** — every `if`, `switch`, `catch` branch hit. Target 100% code and branch coverage. |
| 214 | + |
| 215 | +#### Run tests |
| 216 | + |
| 217 | +```bash |
| 218 | +npm test |
| 219 | +``` |
| 220 | + |
| 221 | +Check coverage in `coverage/lcov-report/index.html`. If coverage is below 100% on the new command file, add tests for missed branches. |
| 222 | + |
| 223 | +### Phase 4: Write Documentation |
| 224 | + |
| 225 | +Create `docs/docs/cmd/<service>/<noun>/<noun>-<verb>.mdx`. |
| 226 | + |
| 227 | +#### Template |
| 228 | + |
| 229 | +````mdx |
| 230 | +import Global from '../../_global.mdx'; |
| 231 | +import Tabs from '@theme/Tabs'; |
| 232 | +import TabItem from '@theme/TabItem'; |
| 233 | + |
| 234 | +# <service> <noun> <verb> |
| 235 | + |
| 236 | +<Description from spec> |
| 237 | + |
| 238 | +## Usage |
| 239 | + |
| 240 | +```sh |
| 241 | +m365 <service> <noun> <verb> [options] |
| 242 | +``` |
| 243 | + |
| 244 | +## Options |
| 245 | + |
| 246 | +```md definition-list |
| 247 | +`-<alias>, --<option> <<option>>` |
| 248 | +: <Description>. <Constraints>. |
| 249 | + |
| 250 | +`--<optionalOption> [<optionalOption>]` |
| 251 | +: <Description>. |
| 252 | +``` |
| 253 | + |
| 254 | +<Global /> |
| 255 | + |
| 256 | +## Permissions |
| 257 | + |
| 258 | +<!-- Generate with: node ./scripts/generate-docs-permissions.mjs --> |
| 259 | + |
| 260 | +<Tabs> |
| 261 | + <TabItem value="Delegated"> |
| 262 | + |
| 263 | + | Resource | Permissions | |
| 264 | + |------------|-------------| |
| 265 | + | ... | ... | |
| 266 | + |
| 267 | + </TabItem> |
| 268 | + <TabItem value="Application"> |
| 269 | + |
| 270 | + | Resource | Permissions | |
| 271 | + |------------|-------------| |
| 272 | + | ... | ... | |
| 273 | + |
| 274 | + </TabItem> |
| 275 | +</Tabs> |
| 276 | + |
| 277 | +## Examples |
| 278 | + |
| 279 | +<At least 2 examples using long option names> |
| 280 | + |
| 281 | +```sh |
| 282 | +m365 <service> <noun> <verb> --<option> <value> |
| 283 | +``` |
| 284 | + |
| 285 | +## Response |
| 286 | + |
| 287 | +<Tabs> |
| 288 | + <TabItem value="JSON"> |
| 289 | + |
| 290 | + ```json |
| 291 | + { ... } |
| 292 | + ``` |
| 293 | + |
| 294 | + </TabItem> |
| 295 | + <TabItem value="Text"> |
| 296 | + |
| 297 | + ```text |
| 298 | + ... |
| 299 | + ``` |
| 300 | + |
| 301 | + </TabItem> |
| 302 | + <TabItem value="CSV"> |
| 303 | + |
| 304 | + ```csv |
| 305 | + ... |
| 306 | + ``` |
| 307 | + |
| 308 | + </TabItem> |
| 309 | + <TabItem value="Markdown"> |
| 310 | + |
| 311 | + ```md |
| 312 | + ... |
| 313 | + ``` |
| 314 | + |
| 315 | + </TabItem> |
| 316 | +</Tabs> |
| 317 | +```` |
| 318 | + |
| 319 | +#### Rules |
| 320 | + |
| 321 | +- Required options: angle brackets `<option>`. Optional: square brackets `[option]`. |
| 322 | +- Examples use **long** option names, start with `m365`. |
| 323 | +- Normalize data: tenant → `contoso`, no real PII. |
| 324 | +- List commands: JSON wrapped in `[ ]` with one item. |
| 325 | +- No output commands: write `The command won't return a response on success.` |
| 326 | +- Add Remarks section between Options and Examples if needed (preview API, 0-based index, etc.). |
| 327 | + |
| 328 | +#### Register in sidebar |
| 329 | + |
| 330 | +Edit `docs/src/config/sidebars.ts`. Find the correct service section, locate or create the command group, add the doc entry alphabetically: |
| 331 | + |
| 332 | +```typescript |
| 333 | +{ |
| 334 | + type: 'doc', |
| 335 | + label: '<noun> <verb>', |
| 336 | + id: 'cmd/<service>/<noun>/<noun>-<verb>' |
| 337 | +} |
| 338 | +``` |
| 339 | + |
| 340 | +### Phase 5: Verify |
| 341 | + |
| 342 | +**STOP — Read `references/pr-checklist.md` and verify every item passes before declaring done.** |
| 343 | + |
| 344 | +1. Run `npm run build` — must pass. |
| 345 | +2. Run `npm test` — all tests green. |
| 346 | +3. **STOP — Check the coverage output for the new command file.** All four metrics (Stmts, Branch, Funcs, Lines) must show 100%. If any metric is below 100%, add tests for the uncovered lines/branches and re-run until all are 100%. Do NOT proceed until this passes. |
| 347 | +4. Walk through every checklist item in `references/pr-checklist.md`. |
| 348 | +5. Fix any failures before proceeding. |
| 349 | + |
| 350 | +Only after all checks pass is the command complete. |
0 commit comments