Skip to content

Commit 161eaf4

Browse files
Adds a skill for the CLI
1 parent e0c307c commit 161eaf4

6 files changed

Lines changed: 1362 additions & 0 deletions

File tree

Lines changed: 350 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,350 @@
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.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
name: refresh-clim365-skill
3+
description: >-
4+
Regenerate the clim365 skill's command index after adding, removing, or
5+
renaming CLI for Microsoft 365 commands. Use when asked to "refresh the
6+
clim365 skill", "update the command index", "regenerate commands.txt",
7+
or after shipping a new command.
8+
---
9+
10+
# Refresh the clim365 Skill Command Index
11+
12+
Regenerate `skills/clim365/references/commands.txt` so the clim365 skill can discover all current commands.
13+
14+
## Prerequisites
15+
16+
- `allCommandsFull.json` must be up to date. If you just added or changed commands, run `npm run build` first — the build generates this file.
17+
18+
## Workflow
19+
20+
1. **STOP — Confirm `allCommandsFull.json` is current.** If the user just added a command, ask whether they've run `npm run build`. If not, run it now.
21+
2. Run the generation script from the repo root:
22+
```sh
23+
node .github/skills/refresh-clim365-skill/references/write-skill-commands.js
24+
```
25+
3. Verify the output:
26+
```sh
27+
wc -l skills/clim365/references/commands.txt
28+
```
29+
The line count should match the number of commands in `allCommandsFull.json`.
30+
4. Done. The updated `commands.txt` will be picked up by the clim365 skill on next use.

0 commit comments

Comments
 (0)