@codeplans/cli 0.0.0-stage → 0.7.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +25 -2
- package/dist/apply.js +207 -0
- package/dist/cli.js +140 -0
- package/dist/client.js +108 -0
- package/dist/commands/apply.js +46 -0
- package/dist/commands/auth.js +83 -0
- package/dist/commands/read.js +248 -0
- package/dist/commands/write.js +182 -0
- package/dist/commands.js +13 -0
- package/dist/config.js +66 -0
- package/dist/index.js +9 -0
- package/dist/output.js +50 -0
- package/package.json +28 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sai Prakash
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,26 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @codeplans/cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[CodePlans](https://github.com/SylonZero/CodePlans) from your terminal. Check what's waiting on you, script workspace setups, and drive plans, tasks and work items from shell scripts and CI.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install -g @codeplans/cli
|
|
7
|
+
codeplans login --url https://acme.codeplans.app --key cpk_… # key from Settings → API Keys
|
|
8
|
+
codeplans me
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```text
|
|
12
|
+
codeplans products # products with counts
|
|
13
|
+
codeplans plans --status active # plans with progress
|
|
14
|
+
codeplans items --product web --type bug # work items
|
|
15
|
+
codeplans search billing retry # search everything
|
|
16
|
+
codeplans plan create --product web --title "Retry payouts" --asset "Payments API"
|
|
17
|
+
codeplans task add <planId> "Add retry queue" --priority high
|
|
18
|
+
codeplans apply workspace.json --dry-run # set up products, assets, dependencies, people
|
|
19
|
+
codeplans call <any MCP tool> --arg key=value
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Every command takes `--json`. In CI, set `CODEPLANS_URL` and `CODEPLANS_API_KEY` instead of logging in. Exit codes: `0` success, `1` failed, `2` usage, `3` not connected or key refused.
|
|
23
|
+
|
|
24
|
+
Node.js 20 or later; no other dependencies. The full guide, including the `apply` manifest format, is [docs/guides/cli.md](https://github.com/SylonZero/CodePlans/blob/master/docs/guides/cli.md).
|
|
25
|
+
|
|
26
|
+
MIT licensed.
|
package/dist/apply.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// codeplans apply: bring a workspace in line with a setup manifest. Idempotent:
|
|
2
|
+
// products are matched by slug, assets by name within their product,
|
|
3
|
+
// dependencies by their endpoints and type, and responsibilities by person,
|
|
4
|
+
// role and area. Only what's missing is created; nothing is updated or deleted.
|
|
5
|
+
import { CliError, EXIT } from './client.js';
|
|
6
|
+
const ASSET_TYPES = ['app', 'service', 'library', 'datastore', 'platform'];
|
|
7
|
+
const DEP_TYPES = ['depends_on', 'integrates_with', 'aggregates'];
|
|
8
|
+
const RESPONSIBILITIES = ['eng_manager', 'architect', 'contributor'];
|
|
9
|
+
export function slugify(name) {
|
|
10
|
+
return name.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
11
|
+
}
|
|
12
|
+
/** Checks a parsed manifest's shape, naming the path of the first problem. */
|
|
13
|
+
export function validateManifest(raw) {
|
|
14
|
+
const fail = (path, msg) => { throw new CliError(`Manifest ${path}: ${msg}`, EXIT.usage); };
|
|
15
|
+
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
16
|
+
const optStr = (o, k, path) => { if (o[k] !== undefined && typeof o[k] !== 'string')
|
|
17
|
+
fail(`${path}.${k}`, 'must be text'); };
|
|
18
|
+
const optStrs = (o, k, path) => {
|
|
19
|
+
if (o[k] !== undefined && !(Array.isArray(o[k]) && o[k].every((x) => typeof x === 'string')))
|
|
20
|
+
fail(`${path}.${k}`, 'must be a list of text');
|
|
21
|
+
};
|
|
22
|
+
const reqStr = (o, k, path) => { if (typeof o[k] !== 'string' || !o[k].trim())
|
|
23
|
+
fail(`${path}.${k}`, 'is required'); };
|
|
24
|
+
const arr = (o, k, path) => {
|
|
25
|
+
if (o[k] === undefined)
|
|
26
|
+
return [];
|
|
27
|
+
if (!Array.isArray(o[k]))
|
|
28
|
+
fail(`${path}.${k}`, 'must be a list');
|
|
29
|
+
return o[k];
|
|
30
|
+
};
|
|
31
|
+
if (!isObj(raw))
|
|
32
|
+
fail('', 'must be a JSON object with a "products" list');
|
|
33
|
+
const root = raw;
|
|
34
|
+
if (!Array.isArray(root.products) || !root.products.length)
|
|
35
|
+
fail('products', 'must be a non-empty list');
|
|
36
|
+
const slugs = new Set();
|
|
37
|
+
root.products.forEach((p, i) => {
|
|
38
|
+
const path = `products[${i}]`;
|
|
39
|
+
if (!isObj(p))
|
|
40
|
+
fail(path, 'must be an object');
|
|
41
|
+
const prod = p;
|
|
42
|
+
reqStr(prod, 'name', path);
|
|
43
|
+
optStr(prod, 'slug', path);
|
|
44
|
+
optStr(prod, 'description', path);
|
|
45
|
+
optStrs(prod, 'tags', path);
|
|
46
|
+
const slug = prod.slug ?? slugify(prod.name);
|
|
47
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug))
|
|
48
|
+
fail(`${path}.slug`, `"${slug}" must be lowercase letters, digits and single hyphens`);
|
|
49
|
+
if (slugs.has(slug))
|
|
50
|
+
fail(`${path}.slug`, `"${slug}" appears twice`);
|
|
51
|
+
slugs.add(slug);
|
|
52
|
+
const names = new Set();
|
|
53
|
+
arr(prod, 'assets', path).forEach((a, j) => {
|
|
54
|
+
const ap = `${path}.assets[${j}]`;
|
|
55
|
+
if (!isObj(a))
|
|
56
|
+
fail(ap, 'must be an object');
|
|
57
|
+
const asset = a;
|
|
58
|
+
reqStr(asset, 'name', ap);
|
|
59
|
+
if (!ASSET_TYPES.includes(asset.type))
|
|
60
|
+
fail(`${ap}.type`, `must be one of ${ASSET_TYPES.join(', ')}`);
|
|
61
|
+
for (const k of ['layer', 'description', 'repositoryUrl', 'repoPath', 'documentationUrl'])
|
|
62
|
+
optStr(asset, k, ap);
|
|
63
|
+
optStrs(asset, 'tags', ap);
|
|
64
|
+
optStrs(asset, 'owners', ap);
|
|
65
|
+
const key = asset.name.toLowerCase();
|
|
66
|
+
if (names.has(key))
|
|
67
|
+
fail(`${ap}.name`, `"${asset.name}" appears twice in this product`);
|
|
68
|
+
names.add(key);
|
|
69
|
+
});
|
|
70
|
+
arr(prod, 'dependencies', path).forEach((d, j) => {
|
|
71
|
+
const dp = `${path}.dependencies[${j}]`;
|
|
72
|
+
if (!isObj(d))
|
|
73
|
+
fail(dp, 'must be an object');
|
|
74
|
+
const dep = d;
|
|
75
|
+
reqStr(dep, 'from', dp);
|
|
76
|
+
reqStr(dep, 'to', dp);
|
|
77
|
+
optStr(dep, 'description', dp);
|
|
78
|
+
if (dep.type !== undefined && !DEP_TYPES.includes(dep.type))
|
|
79
|
+
fail(`${dp}.type`, `must be one of ${DEP_TYPES.join(', ')}`);
|
|
80
|
+
});
|
|
81
|
+
arr(prod, 'people', path).forEach((x, j) => {
|
|
82
|
+
const pp = `${path}.people[${j}]`;
|
|
83
|
+
if (!isObj(x))
|
|
84
|
+
fail(pp, 'must be an object');
|
|
85
|
+
const person = x;
|
|
86
|
+
reqStr(person, 'email', pp);
|
|
87
|
+
optStr(person, 'area', pp);
|
|
88
|
+
if (!RESPONSIBILITIES.includes(person.responsibility))
|
|
89
|
+
fail(`${pp}.responsibility`, `must be one of ${RESPONSIBILITIES.join(', ')}`);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
return raw;
|
|
93
|
+
}
|
|
94
|
+
/** Applies the manifest, reporting each step as it happens. */
|
|
95
|
+
export async function applyManifest(client, manifest, opts = {}) {
|
|
96
|
+
const results = [];
|
|
97
|
+
const report = (r) => { results.push(r); opts.onResult?.(r); };
|
|
98
|
+
const errorOf = (err) => (err instanceof Error ? err.message : String(err));
|
|
99
|
+
const create = opts.dryRun ? 'would create' : 'created';
|
|
100
|
+
const existing = await client.call('list_products');
|
|
101
|
+
for (const spec of manifest.products) {
|
|
102
|
+
const slug = spec.slug ?? slugify(spec.name);
|
|
103
|
+
const label = slug;
|
|
104
|
+
let detail = null;
|
|
105
|
+
let people = null;
|
|
106
|
+
const found = existing.find((p) => p.slug === slug);
|
|
107
|
+
if (found) {
|
|
108
|
+
detail = await client.call('get_product', { slug });
|
|
109
|
+
if (spec.people?.length)
|
|
110
|
+
people = await client.call('list_product_people', { productId: detail.id });
|
|
111
|
+
report({ kind: 'product', product: label, name: spec.name, status: 'exists' });
|
|
112
|
+
}
|
|
113
|
+
else if (opts.dryRun) {
|
|
114
|
+
report({ kind: 'product', product: label, name: spec.name, status: 'would create' });
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
try {
|
|
118
|
+
await client.call('create_product', { name: spec.name, slug, description: spec.description ?? '', tags: spec.tags ?? [] });
|
|
119
|
+
detail = await client.call('get_product', { slug });
|
|
120
|
+
people = { members: [] };
|
|
121
|
+
report({ kind: 'product', product: label, name: spec.name, status: 'created' });
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
report({ kind: 'product', product: label, name: spec.name, status: 'failed', error: errorOf(err) });
|
|
125
|
+
for (const a of spec.assets ?? [])
|
|
126
|
+
report({ kind: 'asset', product: label, name: a.name, status: 'skipped' });
|
|
127
|
+
for (const d of spec.dependencies ?? [])
|
|
128
|
+
report({ kind: 'dependency', product: label, name: `${d.from} → ${d.to}`, status: 'skipped' });
|
|
129
|
+
for (const p of spec.people ?? [])
|
|
130
|
+
report({ kind: 'person', product: label, name: `${p.email} (${p.responsibility})`, status: 'skipped' });
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// Assets by name, case-insensitive. New ones in a dry run have no id yet.
|
|
135
|
+
const assetIds = new Map((detail?.assets ?? []).map((a) => [a.name.toLowerCase(), a.id]));
|
|
136
|
+
for (const a of spec.assets ?? []) {
|
|
137
|
+
const key = a.name.toLowerCase();
|
|
138
|
+
if (assetIds.has(key)) {
|
|
139
|
+
report({ kind: 'asset', product: label, name: a.name, status: 'exists' });
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (opts.dryRun || !detail) {
|
|
143
|
+
assetIds.set(key, null);
|
|
144
|
+
report({ kind: 'asset', product: label, name: a.name, status: 'would create' });
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
const created = await client.call('create_asset', {
|
|
149
|
+
productId: detail.id, name: a.name, type: a.type, description: a.description ?? '', tags: a.tags ?? [],
|
|
150
|
+
...(a.layer ? { layer: a.layer } : {}), ...(a.repositoryUrl ? { repositoryUrl: a.repositoryUrl } : {}),
|
|
151
|
+
...(a.repoPath ? { repoPath: a.repoPath } : {}), ...(a.documentationUrl ? { documentationUrl: a.documentationUrl } : {}),
|
|
152
|
+
...(a.owners?.length ? { ownerEmails: a.owners } : {}),
|
|
153
|
+
});
|
|
154
|
+
assetIds.set(key, created.id);
|
|
155
|
+
report({ kind: 'asset', product: label, name: a.name, status: create });
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
report({ kind: 'asset', product: label, name: a.name, status: 'failed', error: errorOf(err) });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
for (const d of spec.dependencies ?? []) {
|
|
162
|
+
const name = `${d.from} → ${d.to}`;
|
|
163
|
+
const type = d.type ?? 'depends_on';
|
|
164
|
+
const from = assetIds.get(d.from.toLowerCase());
|
|
165
|
+
const to = assetIds.get(d.to.toLowerCase());
|
|
166
|
+
if (from === undefined || to === undefined) {
|
|
167
|
+
report({ kind: 'dependency', product: label, name, status: 'failed', error: `No asset named "${from === undefined ? d.from : d.to}" in ${label}` });
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (from && to && detail?.dependencies.some((e) => e.sourceAssetId === from && e.targetAssetId === to && e.dependencyType === type)) {
|
|
171
|
+
report({ kind: 'dependency', product: label, name, status: 'exists' });
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (opts.dryRun || !from || !to) {
|
|
175
|
+
report({ kind: 'dependency', product: label, name, status: 'would create' });
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
await client.call('add_asset_dependency', { sourceAssetId: from, targetAssetId: to, dependencyType: type, ...(d.description ? { description: d.description } : {}) });
|
|
180
|
+
report({ kind: 'dependency', product: label, name, status: create });
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
report({ kind: 'dependency', product: label, name, status: 'failed', error: errorOf(err) });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
for (const p of spec.people ?? []) {
|
|
187
|
+
const name = `${p.email} (${p.responsibility}${p.area ? `, ${p.area}` : ''})`;
|
|
188
|
+
const has = people?.members.some((m) => m.email.toLowerCase() === p.email.toLowerCase() && m.responsibility === p.responsibility && (m.area ?? null) === (p.area ?? null));
|
|
189
|
+
if (has) {
|
|
190
|
+
report({ kind: 'person', product: label, name, status: 'exists' });
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (opts.dryRun || !detail) {
|
|
194
|
+
report({ kind: 'person', product: label, name, status: 'would create' });
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
await client.call('assign_responsibility', { productId: detail.id, email: p.email, responsibility: p.responsibility, ...(p.area ? { area: p.area } : {}) });
|
|
199
|
+
report({ kind: 'person', product: label, name, status: create });
|
|
200
|
+
}
|
|
201
|
+
catch (err) {
|
|
202
|
+
report({ kind: 'person', product: label, name, status: 'failed', error: errorOf(err) });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return results;
|
|
207
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Command registry and runner. Every command declares its options for
|
|
2
|
+
// node:util parseArgs; the runner adds --json, --profile and --help, turns
|
|
3
|
+
// errors into messages on stderr and exit codes, and creates the client lazily
|
|
4
|
+
// (login and help never need one).
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
import { parseArgs } from 'node:util';
|
|
7
|
+
import { Client, CliError, EXIT } from './client.js';
|
|
8
|
+
import { configPath, resolveConnection } from './config.js';
|
|
9
|
+
import { createOut, paint, table } from './output.js';
|
|
10
|
+
/** From the package's own package.json (one level up from both src/ and dist/), so it can't drift. */
|
|
11
|
+
export const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
12
|
+
const GLOBAL_OPTIONS = {
|
|
13
|
+
json: { type: 'boolean' },
|
|
14
|
+
profile: { type: 'string' },
|
|
15
|
+
help: { type: 'boolean', short: 'h' },
|
|
16
|
+
};
|
|
17
|
+
/** Runs the CLI and returns the exit code. */
|
|
18
|
+
export async function run(argv, commands, deps = {}) {
|
|
19
|
+
const env = deps.env ?? process.env;
|
|
20
|
+
const wantsJson = argv.includes('--json');
|
|
21
|
+
const out = createOut(wantsJson, deps.stdout ?? process.stdout, deps.stderr ?? process.stderr, env);
|
|
22
|
+
// The command comes first: one or two words ("plan", "plan create"); the longest match wins.
|
|
23
|
+
const lead = [];
|
|
24
|
+
for (const a of argv) {
|
|
25
|
+
if (a.startsWith('-'))
|
|
26
|
+
break;
|
|
27
|
+
lead.push(a);
|
|
28
|
+
}
|
|
29
|
+
const find = (name) => commands.find((c) => c.name === name || c.aliases?.includes(name));
|
|
30
|
+
const two = lead.length >= 2 ? find(`${lead[0]} ${lead[1]}`) : undefined;
|
|
31
|
+
const command = two ?? (lead.length ? find(lead[0]) : undefined);
|
|
32
|
+
if (!command) {
|
|
33
|
+
if (argv.includes('--version') || argv.includes('-v')) {
|
|
34
|
+
out.print(VERSION);
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
if (!lead.length || lead[0] === 'help') {
|
|
38
|
+
const topic = lead.slice(1);
|
|
39
|
+
printHelp(out, commands, topic.length ? find(topic.join(' ')) ?? find(topic[0]) : undefined);
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
out.error(`Unknown command "${lead[0]}". Run codeplans help for the list.`);
|
|
43
|
+
return EXIT.usage;
|
|
44
|
+
}
|
|
45
|
+
const rest = argv.slice(two ? 2 : 1);
|
|
46
|
+
let positionals;
|
|
47
|
+
let values;
|
|
48
|
+
try {
|
|
49
|
+
const parsed = parseArgs({ args: rest, options: { ...GLOBAL_OPTIONS, ...command.options }, allowPositionals: true, strict: true });
|
|
50
|
+
positionals = parsed.positionals;
|
|
51
|
+
values = parsed.values;
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
out.error(`${err instanceof Error ? err.message : String(err)}\nUsage: ${command.usage}`);
|
|
55
|
+
return EXIT.usage;
|
|
56
|
+
}
|
|
57
|
+
if (values.help) {
|
|
58
|
+
printHelp(out, commands, command);
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
if (positionals.length < (command.minArgs ?? 0)) {
|
|
62
|
+
out.error(`Usage: ${command.usage}`);
|
|
63
|
+
return EXIT.usage;
|
|
64
|
+
}
|
|
65
|
+
let cachedClient;
|
|
66
|
+
const ctx = {
|
|
67
|
+
out,
|
|
68
|
+
env,
|
|
69
|
+
configFile: configPath(env),
|
|
70
|
+
profile: values.profile,
|
|
71
|
+
fetch: deps.fetch ?? fetch,
|
|
72
|
+
connection: () => resolveConnection({ profile: values.profile }, env, configPath(env)),
|
|
73
|
+
client: () => {
|
|
74
|
+
if (!cachedClient) {
|
|
75
|
+
const conn = ctx.connection();
|
|
76
|
+
cachedClient = new Client(conn.url, conn.key, ctx.fetch);
|
|
77
|
+
}
|
|
78
|
+
return cachedClient;
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
try {
|
|
82
|
+
await command.run(ctx, positionals, values);
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
if (err instanceof CliError) {
|
|
87
|
+
out.error(paint({ color: !!(deps.stderr ?? process.stderr).isTTY && !env.NO_COLOR }, 'red', err.message));
|
|
88
|
+
return err.exitCode;
|
|
89
|
+
}
|
|
90
|
+
out.error(`Unexpected error: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
|
|
91
|
+
return EXIT.failed;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function printHelp(out, commands, command) {
|
|
95
|
+
if (command) {
|
|
96
|
+
out.print(`Usage: ${command.usage}`);
|
|
97
|
+
out.print();
|
|
98
|
+
out.print(command.summary);
|
|
99
|
+
if (command.details) {
|
|
100
|
+
out.print();
|
|
101
|
+
out.print(command.details);
|
|
102
|
+
}
|
|
103
|
+
out.print();
|
|
104
|
+
out.print('Global options: --json (machine-readable output), --profile <name>, --help');
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
out.print(`codeplans ${VERSION}: CodePlans from your terminal`);
|
|
108
|
+
out.print();
|
|
109
|
+
out.print('Usage: codeplans <command> [options]');
|
|
110
|
+
out.print();
|
|
111
|
+
for (const line of table(commands.map((c) => [` ${c.name}`, c.summary])))
|
|
112
|
+
out.print(line);
|
|
113
|
+
out.print();
|
|
114
|
+
out.print('Global options: --json --profile <name> --help --version');
|
|
115
|
+
out.print('Connect with: codeplans login --url https://your-codeplans --key cpk_…');
|
|
116
|
+
out.print('Docs: https://github.com/SylonZero/CodePlans/blob/master/docs/guides/cli.md');
|
|
117
|
+
}
|
|
118
|
+
/** A product by id, slug or (case-insensitive) name. */
|
|
119
|
+
export async function resolveProduct(client, ref) {
|
|
120
|
+
const products = await client.call('list_products');
|
|
121
|
+
const lower = ref.toLowerCase();
|
|
122
|
+
const match = products.find((p) => p.id === ref || p.slug === lower)
|
|
123
|
+
?? products.filter((p) => p.name.toLowerCase() === lower).at(0);
|
|
124
|
+
if (!match)
|
|
125
|
+
throw new CliError(`No product "${ref}". Products: ${products.map((p) => p.slug).join(', ') || 'none'}.`);
|
|
126
|
+
return match;
|
|
127
|
+
}
|
|
128
|
+
export function str(v) {
|
|
129
|
+
return typeof v === 'string' ? v : undefined;
|
|
130
|
+
}
|
|
131
|
+
export function list(v) {
|
|
132
|
+
return Array.isArray(v) ? v : typeof v === 'string' ? [v] : [];
|
|
133
|
+
}
|
|
134
|
+
export function oneOf(value, allowed, flag) {
|
|
135
|
+
if (value === undefined)
|
|
136
|
+
return undefined;
|
|
137
|
+
if (!allowed.includes(value))
|
|
138
|
+
throw new CliError(`--${flag} must be one of: ${allowed.join(', ')}`, EXIT.usage);
|
|
139
|
+
return value;
|
|
140
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Talks to a CodePlans server through its authenticated MCP endpoint
|
|
2
|
+
// (/api/mcp/mcp). The endpoint is stateless, so each call is one JSON-RPC POST;
|
|
3
|
+
// the response is either JSON or a server-sent event stream.
|
|
4
|
+
/** Exit codes: 1 a failed operation, 2 a usage error, 3 authentication or connection. */
|
|
5
|
+
export const EXIT = { failed: 1, usage: 2, auth: 3 };
|
|
6
|
+
export class CliError extends Error {
|
|
7
|
+
exitCode;
|
|
8
|
+
constructor(message, exitCode = EXIT.failed) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.exitCode = exitCode;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
const PROTOCOL_VERSION = '2025-06-18';
|
|
14
|
+
export class Client {
|
|
15
|
+
baseUrl;
|
|
16
|
+
key;
|
|
17
|
+
fetchImpl;
|
|
18
|
+
nextId = 1;
|
|
19
|
+
endpoint;
|
|
20
|
+
constructor(baseUrl, key, fetchImpl = fetch) {
|
|
21
|
+
this.baseUrl = baseUrl;
|
|
22
|
+
this.key = key;
|
|
23
|
+
this.fetchImpl = fetchImpl;
|
|
24
|
+
this.endpoint = `${baseUrl.replace(/\/+$/, '')}/api/mcp/mcp`;
|
|
25
|
+
}
|
|
26
|
+
/** Calls a tool and returns its JSON result. A result carrying an `error` string is thrown as a CliError. */
|
|
27
|
+
async call(name, args = {}) {
|
|
28
|
+
const result = (await this.rpc('tools/call', { name, arguments: args }));
|
|
29
|
+
const text = (result.content ?? []).filter((c) => c.type === 'text').map((c) => c.text ?? '').join('\n');
|
|
30
|
+
if (result.isError)
|
|
31
|
+
throw new CliError(text || `${name} failed`);
|
|
32
|
+
let data = text;
|
|
33
|
+
try {
|
|
34
|
+
data = text ? JSON.parse(text) : null;
|
|
35
|
+
}
|
|
36
|
+
catch { /* plain text result */ }
|
|
37
|
+
if (data && typeof data === 'object' && !Array.isArray(data) && typeof data.error === 'string') {
|
|
38
|
+
throw new CliError(data.error);
|
|
39
|
+
}
|
|
40
|
+
return data;
|
|
41
|
+
}
|
|
42
|
+
async listTools() {
|
|
43
|
+
const result = (await this.rpc('tools/list', {}));
|
|
44
|
+
return result.tools ?? [];
|
|
45
|
+
}
|
|
46
|
+
async rpc(method, params) {
|
|
47
|
+
const id = this.nextId++;
|
|
48
|
+
let res;
|
|
49
|
+
try {
|
|
50
|
+
res = await this.fetchImpl(this.endpoint, {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: {
|
|
53
|
+
'content-type': 'application/json',
|
|
54
|
+
accept: 'application/json, text/event-stream',
|
|
55
|
+
authorization: `Bearer ${this.key}`,
|
|
56
|
+
'mcp-protocol-version': PROTOCOL_VERSION,
|
|
57
|
+
},
|
|
58
|
+
body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
const cause = err instanceof Error && err.cause instanceof Error ? `: ${err.cause.message}` : '';
|
|
63
|
+
throw new CliError(`Could not reach ${this.baseUrl}${cause}`, EXIT.auth);
|
|
64
|
+
}
|
|
65
|
+
if (res.status === 401 || res.status === 403) {
|
|
66
|
+
throw new CliError(`The server at ${this.baseUrl} refused the API key. Check it in Settings → API Keys, or run codeplans login again.`, EXIT.auth);
|
|
67
|
+
}
|
|
68
|
+
if (res.status === 404)
|
|
69
|
+
throw new CliError(`No CodePlans MCP endpoint at ${this.endpoint}. Is the URL right?`, EXIT.auth);
|
|
70
|
+
const body = await res.text();
|
|
71
|
+
if (!res.ok)
|
|
72
|
+
throw new CliError(`The server answered ${res.status}${body ? `: ${body.slice(0, 200)}` : ''}`, res.status >= 500 ? EXIT.failed : EXIT.auth);
|
|
73
|
+
const message = parseResponse(body, res.headers.get('content-type') ?? '', id);
|
|
74
|
+
if (message.error) {
|
|
75
|
+
// A thrown tool error (access, a review-workflow block, validation) arrives as a JSON-RPC error.
|
|
76
|
+
throw new CliError(message.error.message);
|
|
77
|
+
}
|
|
78
|
+
return message.result;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** Reads the JSON-RPC response with the given id from a JSON body or a server-sent event stream. */
|
|
82
|
+
export function parseResponse(body, contentType, id) {
|
|
83
|
+
const candidates = [];
|
|
84
|
+
if (contentType.includes('text/event-stream')) {
|
|
85
|
+
for (const event of body.split(/\r?\n\r?\n/)) {
|
|
86
|
+
const data = event.split(/\r?\n/).filter((l) => l.startsWith('data:')).map((l) => l.slice(5).trimStart()).join('\n');
|
|
87
|
+
if (!data)
|
|
88
|
+
continue;
|
|
89
|
+
try {
|
|
90
|
+
candidates.push(JSON.parse(data));
|
|
91
|
+
}
|
|
92
|
+
catch { /* not JSON: skip */ }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
try {
|
|
97
|
+
const parsed = JSON.parse(body);
|
|
98
|
+
candidates.push(...(Array.isArray(parsed) ? parsed : [parsed]));
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
throw new CliError(`Unexpected response from the server: ${body.slice(0, 200)}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const match = candidates.find((m) => m.id === id) ?? candidates.find((m) => 'result' in m || 'error' in m);
|
|
105
|
+
if (!match)
|
|
106
|
+
throw new CliError('The server sent no response to the request.');
|
|
107
|
+
return match;
|
|
108
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { CliError, EXIT } from '../client.js';
|
|
3
|
+
import { applyManifest, validateManifest } from '../apply.js';
|
|
4
|
+
import { paint, printJson } from '../output.js';
|
|
5
|
+
const MARK = { created: '+', 'would create': '+', exists: '=', failed: '!', skipped: '-' };
|
|
6
|
+
export const apply = {
|
|
7
|
+
name: 'apply',
|
|
8
|
+
summary: 'Create the products, assets, dependencies and responsibilities a setup manifest describes',
|
|
9
|
+
usage: 'codeplans apply <manifest.json|-> [--dry-run]',
|
|
10
|
+
details: [
|
|
11
|
+
'Re-running is safe: products match by slug, assets by name, dependencies by their endpoints,',
|
|
12
|
+
'and responsibilities by person and role. Only what is missing is created; nothing is changed or deleted.',
|
|
13
|
+
'Use --dry-run to see what would be created. See docs/guides/cli.md for the manifest format.',
|
|
14
|
+
].join('\n'),
|
|
15
|
+
options: { 'dry-run': { type: 'boolean' } },
|
|
16
|
+
minArgs: 1,
|
|
17
|
+
async run(ctx, [file], values) {
|
|
18
|
+
let raw;
|
|
19
|
+
try {
|
|
20
|
+
raw = JSON.parse(readFileSync(file === '-' ? 0 : file, 'utf8'));
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
throw new CliError(`Can't read ${file === '-' ? 'stdin' : file} as JSON: ${err instanceof Error ? err.message : err}`, EXIT.usage);
|
|
24
|
+
}
|
|
25
|
+
const manifest = validateManifest(raw);
|
|
26
|
+
const dryRun = !!values['dry-run'];
|
|
27
|
+
const { out } = ctx;
|
|
28
|
+
const style = (s) => (s === 'failed' ? 'red' : s === 'exists' || s === 'skipped' ? 'dim' : 'green');
|
|
29
|
+
const results = await applyManifest(ctx.client(), manifest, {
|
|
30
|
+
dryRun,
|
|
31
|
+
onResult: out.json ? undefined : (r) => out.print(paint(out, style(r.status), `${MARK[r.status]} ${r.kind.padEnd(10)} ${r.product === r.name ? r.name : `${r.product}: ${r.name}`} ${r.status}${r.error ? ` (${r.error})` : ''}`)),
|
|
32
|
+
});
|
|
33
|
+
const count = (s) => results.filter((r) => r.status === s).length;
|
|
34
|
+
const failed = count('failed');
|
|
35
|
+
if (out.json)
|
|
36
|
+
printJson(out, { dryRun, results });
|
|
37
|
+
else {
|
|
38
|
+
out.print();
|
|
39
|
+
out.print(dryRun
|
|
40
|
+
? `Dry run: ${count('would create')} to create, ${count('exists')} already there.`
|
|
41
|
+
: `${count('created')} created, ${count('exists')} already there${failed ? `, ${failed} failed` : ''}${count('skipped') ? `, ${count('skipped')} skipped` : ''}.`);
|
|
42
|
+
}
|
|
43
|
+
if (failed)
|
|
44
|
+
throw new CliError(`${failed} step${failed === 1 ? '' : 's'} failed.`);
|
|
45
|
+
},
|
|
46
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { Client, CliError, EXIT } from '../client.js';
|
|
2
|
+
import { maskKey, normalizeUrl, readConfig, writeConfig } from '../config.js';
|
|
3
|
+
import { paint, printJson, table } from '../output.js';
|
|
4
|
+
import { str } from '../cli.js';
|
|
5
|
+
export const login = {
|
|
6
|
+
name: 'login',
|
|
7
|
+
summary: 'Save a connection (URL and API key) as a profile, after checking it works',
|
|
8
|
+
usage: 'codeplans login --url <url> --key <cpk_…> [--profile <name>]',
|
|
9
|
+
details: [
|
|
10
|
+
'Create an API key in CodePlans under Settings → API Keys. Read keys can list and read;',
|
|
11
|
+
'read/write keys can also change things. The profile is saved to ~/.config/codeplans/config.json',
|
|
12
|
+
'(mode 0600) and becomes the current one. In CI, set CODEPLANS_URL and CODEPLANS_API_KEY instead.',
|
|
13
|
+
].join('\n'),
|
|
14
|
+
options: { url: { type: 'string' }, key: { type: 'string' } },
|
|
15
|
+
async run(ctx, _args, values) {
|
|
16
|
+
const rawUrl = str(values.url) ?? ctx.env.CODEPLANS_URL;
|
|
17
|
+
const key = str(values.key) ?? ctx.env.CODEPLANS_API_KEY;
|
|
18
|
+
if (!rawUrl || !key)
|
|
19
|
+
throw new CliError('Usage: codeplans login --url <url> --key <cpk_…>', EXIT.usage);
|
|
20
|
+
if (!key.startsWith('cpk_'))
|
|
21
|
+
throw new CliError('That doesn\'t look like a CodePlans API key (they start with cpk_).', EXIT.usage);
|
|
22
|
+
const url = normalizeUrl(rawUrl);
|
|
23
|
+
const products = await new Client(url, key, ctx.fetch).call('list_products');
|
|
24
|
+
const name = ctx.profile ?? 'default';
|
|
25
|
+
const config = readConfig(ctx.configFile);
|
|
26
|
+
config.profiles[name] = { url, key };
|
|
27
|
+
config.current = name;
|
|
28
|
+
writeConfig(config, ctx.configFile);
|
|
29
|
+
if (ctx.out.json)
|
|
30
|
+
return printJson(ctx.out, { profile: name, url, products: products.length });
|
|
31
|
+
ctx.out.print(`${paint(ctx.out, 'green', '✓')} Connected to ${url} as profile "${name}" (${products.length} product${products.length === 1 ? '' : 's'} visible).`);
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
export const logout = {
|
|
35
|
+
name: 'logout',
|
|
36
|
+
summary: 'Remove a saved profile (the current one unless --profile is given)',
|
|
37
|
+
usage: 'codeplans logout [--profile <name>]',
|
|
38
|
+
async run(ctx) {
|
|
39
|
+
const config = readConfig(ctx.configFile);
|
|
40
|
+
const name = ctx.profile ?? config.current ?? 'default';
|
|
41
|
+
if (!config.profiles[name])
|
|
42
|
+
throw new CliError(`No profile named "${name}".`, EXIT.usage);
|
|
43
|
+
delete config.profiles[name];
|
|
44
|
+
if (config.current === name)
|
|
45
|
+
config.current = Object.keys(config.profiles)[0];
|
|
46
|
+
writeConfig(config, ctx.configFile);
|
|
47
|
+
if (ctx.out.json)
|
|
48
|
+
return printJson(ctx.out, { removed: name, current: config.current ?? null });
|
|
49
|
+
ctx.out.print(`Removed profile "${name}".${config.current ? ` Current profile: "${config.current}".` : ''}`);
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
export const status = {
|
|
53
|
+
name: 'status',
|
|
54
|
+
summary: 'Show the connection in use and check that it works',
|
|
55
|
+
usage: 'codeplans status',
|
|
56
|
+
async run(ctx) {
|
|
57
|
+
const conn = ctx.connection();
|
|
58
|
+
const config = readConfig(ctx.configFile);
|
|
59
|
+
let products = null;
|
|
60
|
+
let problem = null;
|
|
61
|
+
try {
|
|
62
|
+
products = await ctx.client().call('list_products');
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
problem = err instanceof Error ? err.message : String(err);
|
|
66
|
+
}
|
|
67
|
+
if (ctx.out.json) {
|
|
68
|
+
printJson(ctx.out, { url: conn.url, profile: conn.profile, source: conn.source, key: maskKey(conn.key), ok: !problem, products: products?.length ?? null, error: problem, profiles: Object.keys(config.profiles) });
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
for (const line of table([
|
|
72
|
+
['URL', conn.url],
|
|
73
|
+
['Profile', conn.source === 'env' ? 'environment (CODEPLANS_URL, CODEPLANS_API_KEY)' : conn.profile ?? ''],
|
|
74
|
+
['API key', maskKey(conn.key)],
|
|
75
|
+
['Status', problem ? paint(ctx.out, 'red', problem) : paint(ctx.out, 'green', `connected, ${products.length} product${products.length === 1 ? '' : 's'} visible`)],
|
|
76
|
+
...(Object.keys(config.profiles).length > 1 ? [['Profiles', Object.keys(config.profiles).join(', ')]] : []),
|
|
77
|
+
]))
|
|
78
|
+
ctx.out.print(line);
|
|
79
|
+
}
|
|
80
|
+
if (problem)
|
|
81
|
+
throw new CliError('Not connected.', EXIT.auth);
|
|
82
|
+
},
|
|
83
|
+
};
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { CliError, EXIT } from '../client.js';
|
|
2
|
+
import { ago, paint, printJson, table, truncate } from '../output.js';
|
|
3
|
+
import { list, oneOf, resolveProduct, str } from '../cli.js';
|
|
4
|
+
const TASK_MARK = { not_started: '○', in_progress: '◐', done: '●' };
|
|
5
|
+
export const me = {
|
|
6
|
+
name: 'me',
|
|
7
|
+
aliases: ['my-work'],
|
|
8
|
+
summary: "What's waiting on you: reviews, assignments, overdue tasks, and work in flight",
|
|
9
|
+
usage: 'codeplans me [--product <slug>]',
|
|
10
|
+
options: { product: { type: 'string' } },
|
|
11
|
+
async run(ctx, _args, values) {
|
|
12
|
+
const client = ctx.client();
|
|
13
|
+
const productId = str(values.product) ? (await resolveProduct(client, str(values.product))).id : undefined;
|
|
14
|
+
const work = await client.call('get_my_work', productId ? { productId } : {});
|
|
15
|
+
if (ctx.out.json)
|
|
16
|
+
return printJson(ctx.out, work);
|
|
17
|
+
const { out } = ctx;
|
|
18
|
+
out.print(paint(out, 'bold', `Needs you (${work.needsYou.length})`));
|
|
19
|
+
if (!work.needsYou.length)
|
|
20
|
+
out.print(paint(out, 'dim', ' Nothing waiting on you.'));
|
|
21
|
+
for (const line of table(work.needsYou.map((i) => [
|
|
22
|
+
` ${i.urgent ? paint(out, 'red', i.verb) : paint(out, 'cyan', i.verb)}`,
|
|
23
|
+
truncate(i.title, 60),
|
|
24
|
+
paint(out, 'dim', [i.detail ?? i.reason, i.productName].filter(Boolean).join(' · ')),
|
|
25
|
+
])))
|
|
26
|
+
out.print(line);
|
|
27
|
+
const groups = work.inFlight.taskGroups;
|
|
28
|
+
if (groups.length) {
|
|
29
|
+
out.print();
|
|
30
|
+
out.print(paint(out, 'bold', 'Your tasks'));
|
|
31
|
+
for (const g of groups) {
|
|
32
|
+
out.print(` ${g.planTitle} ${paint(out, 'dim', `· ${g.productName} · plan ${g.planId}`)}`);
|
|
33
|
+
for (const line of table(g.tasks.map((t) => [` ${TASK_MARK[t.status] ?? '·'}`, t.overdue ? paint(out, 'red', t.title) : t.title, paint(out, 'dim', t.id)])))
|
|
34
|
+
out.print(line);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (work.inFlight.plans.length) {
|
|
38
|
+
out.print();
|
|
39
|
+
out.print(paint(out, 'bold', 'Plans you own'));
|
|
40
|
+
for (const line of table(work.inFlight.plans.map((p) => [` ${p.title}`, p.status, `${Math.round(p.progress)}%`, p.atRisk ? paint(out, 'red', 'at risk') : '', paint(out, 'dim', p.id)])))
|
|
41
|
+
out.print(line);
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
export const products = {
|
|
46
|
+
name: 'products',
|
|
47
|
+
summary: 'List the products you can see',
|
|
48
|
+
usage: 'codeplans products',
|
|
49
|
+
async run(ctx) {
|
|
50
|
+
const rows = await ctx.client().call('list_products');
|
|
51
|
+
if (ctx.out.json)
|
|
52
|
+
return printJson(ctx.out, rows);
|
|
53
|
+
if (!rows.length)
|
|
54
|
+
return ctx.out.print('No products yet.');
|
|
55
|
+
for (const line of table(rows.map((p) => [p.slug, p.name, String(p.assetCount ?? ''), String(p.activePlanCount ?? ''), String(p.workItemCount ?? '')]), ['SLUG', 'NAME', 'ASSETS', 'ACTIVE PLANS', 'WORK ITEMS'], ctx.out))
|
|
56
|
+
ctx.out.print(line);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
export const product = {
|
|
60
|
+
name: 'product',
|
|
61
|
+
summary: "One product's assets and their dependencies",
|
|
62
|
+
usage: 'codeplans product <slug>',
|
|
63
|
+
minArgs: 1,
|
|
64
|
+
async run(ctx, [ref]) {
|
|
65
|
+
const client = ctx.client();
|
|
66
|
+
const slug = (await resolveProduct(client, ref)).slug;
|
|
67
|
+
const p = await client.call('get_product', { slug });
|
|
68
|
+
if (ctx.out.json)
|
|
69
|
+
return printJson(ctx.out, p);
|
|
70
|
+
const { out } = ctx;
|
|
71
|
+
out.print(`${paint(out, 'bold', p.name)} ${paint(out, 'dim', `(${p.slug})`)}`);
|
|
72
|
+
if (p.description)
|
|
73
|
+
out.print(truncate(p.description, 160));
|
|
74
|
+
out.print();
|
|
75
|
+
for (const line of table(p.assets.map((a) => [a.name, a.type, a.layer ?? '', a.health, String(a.openDebtCount ?? 0), (a.owners ?? []).map((o) => o.name).join(', ')]), ['ASSET', 'TYPE', 'LAYER', 'HEALTH', 'DEBT', 'CODE OWNERS'], out))
|
|
76
|
+
out.print(line);
|
|
77
|
+
if (p.dependencies.length) {
|
|
78
|
+
out.print();
|
|
79
|
+
out.print(paint(out, 'bold', 'Dependencies'));
|
|
80
|
+
for (const line of table(p.dependencies.map((d) => [` ${d.sourceAssetName}`, paint(out, 'dim', d.dependencyType.replace('_', ' ')), d.targetAssetName])))
|
|
81
|
+
out.print(line);
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
const PLAN_STATUSES = ['draft', 'active', 'completed', 'cancelled'];
|
|
86
|
+
const PLAN_TYPES = ['refactor', 'feature', 'improvement', 'bugfix'];
|
|
87
|
+
export const plans = {
|
|
88
|
+
name: 'plans',
|
|
89
|
+
summary: 'List code plans with progress',
|
|
90
|
+
usage: 'codeplans plans [--product <slug>] [--status draft|active|completed|cancelled] [--type <type>]',
|
|
91
|
+
options: { product: { type: 'string' }, status: { type: 'string' }, type: { type: 'string' } },
|
|
92
|
+
async run(ctx, _args, values) {
|
|
93
|
+
const client = ctx.client();
|
|
94
|
+
const filters = {};
|
|
95
|
+
if (str(values.product))
|
|
96
|
+
filters.productId = (await resolveProduct(client, str(values.product))).id;
|
|
97
|
+
const status = oneOf(str(values.status), PLAN_STATUSES, 'status');
|
|
98
|
+
const type = oneOf(str(values.type), PLAN_TYPES, 'type');
|
|
99
|
+
if (status)
|
|
100
|
+
filters.status = status;
|
|
101
|
+
if (type)
|
|
102
|
+
filters.type = type;
|
|
103
|
+
const rows = await client.call('list_code_plans', filters);
|
|
104
|
+
if (ctx.out.json)
|
|
105
|
+
return printJson(ctx.out, rows);
|
|
106
|
+
if (!rows.length)
|
|
107
|
+
return ctx.out.print('No plans match.');
|
|
108
|
+
for (const line of table(rows.map((p) => [
|
|
109
|
+
p.id, truncate(p.title, 50), p.status, p.type,
|
|
110
|
+
p.taskCount ? `${p.completedTaskCount ?? 0}/${p.taskCount}` : '-',
|
|
111
|
+
p.productName ?? '',
|
|
112
|
+
]), ['ID', 'TITLE', 'STATUS', 'TYPE', 'TASKS', 'PRODUCT'], ctx.out))
|
|
113
|
+
ctx.out.print(line);
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
export const plan = {
|
|
117
|
+
name: 'plan',
|
|
118
|
+
summary: "A plan's tasks, assets and PRs, and the work items it addresses",
|
|
119
|
+
usage: 'codeplans plan <planId>',
|
|
120
|
+
minArgs: 1,
|
|
121
|
+
async run(ctx, [id]) {
|
|
122
|
+
const p = await ctx.client().call('get_code_plan', { id });
|
|
123
|
+
if (ctx.out.json)
|
|
124
|
+
return printJson(ctx.out, p);
|
|
125
|
+
const { out } = ctx;
|
|
126
|
+
out.print(`${paint(out, 'bold', p.title)} ${paint(out, 'dim', `· ${p.status} · ${p.type}${p.revision ? ` · rev ${p.revision}` : ''}${p.productName ? ` · ${p.productName}` : ''}`)}`);
|
|
127
|
+
if (p.description) {
|
|
128
|
+
out.print();
|
|
129
|
+
out.print(truncate(p.description.replace(/\s+/g, ' '), 300));
|
|
130
|
+
}
|
|
131
|
+
const tasks = p.tasks ?? [];
|
|
132
|
+
out.print();
|
|
133
|
+
out.print(paint(out, 'bold', `Tasks (${tasks.filter((t) => t.status === 'done').length}/${tasks.length} done)`));
|
|
134
|
+
if (!tasks.length)
|
|
135
|
+
out.print(paint(out, 'dim', ' No tasks yet. Add one: codeplans task add <planId> "<title>"'));
|
|
136
|
+
const names = new Map((p.assignees ?? []).map((a) => [a.id, a.name]));
|
|
137
|
+
for (const line of table(tasks.map((t) => [` ${TASK_MARK[t.status] ?? '·'}`, truncate(t.title, 60), t.priority, (t.assigneeId && names.get(t.assigneeId)) || '', paint(out, 'dim', t.id)])))
|
|
138
|
+
out.print(line);
|
|
139
|
+
const assets = p.planAssets ?? [];
|
|
140
|
+
if (assets.length) {
|
|
141
|
+
out.print();
|
|
142
|
+
out.print(paint(out, 'bold', 'Assets'));
|
|
143
|
+
for (const line of table(assets.map((a) => [` ${a.assetName}`, a.branch ?? '', a.prStatus && a.prStatus !== 'none' ? a.prStatus : '', a.prUrl ?? ''])))
|
|
144
|
+
out.print(line);
|
|
145
|
+
}
|
|
146
|
+
const items = p.linkedWorkItems ?? [];
|
|
147
|
+
if (items.length) {
|
|
148
|
+
out.print();
|
|
149
|
+
out.print(paint(out, 'bold', 'Work items'));
|
|
150
|
+
for (const line of table(items.map((w) => [` ${truncate(w.title, 60)}`, w.type.replace('_', ' '), w.status.replace('_', ' '), paint(out, 'dim', w.id)])))
|
|
151
|
+
out.print(line);
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
const ITEM_TYPES = ['feature', 'bug', 'enhancement', 'ux', 'tech_debt'];
|
|
156
|
+
const ITEM_STATUSES = ['open', 'planned', 'in_progress', 'resolved', 'wont_do'];
|
|
157
|
+
const SEVERITIES = ['low', 'medium', 'high', 'critical'];
|
|
158
|
+
export const items = {
|
|
159
|
+
name: 'items',
|
|
160
|
+
summary: 'List work items (features, bugs, UX issues, tech debt)',
|
|
161
|
+
usage: 'codeplans items [--product <slug>] [--type <type>] [--status <status>] [--severity <s>] [--tag <tag>] [--plan <planId>] [--limit <n>] [--cursor <c>]',
|
|
162
|
+
options: { product: { type: 'string' }, type: { type: 'string' }, status: { type: 'string' }, severity: { type: 'string' }, tag: { type: 'string' }, plan: { type: 'string' }, limit: { type: 'string' }, cursor: { type: 'string' } },
|
|
163
|
+
async run(ctx, _args, values) {
|
|
164
|
+
const client = ctx.client();
|
|
165
|
+
const args = {};
|
|
166
|
+
if (str(values.product))
|
|
167
|
+
args.productId = (await resolveProduct(client, str(values.product))).id;
|
|
168
|
+
const type = oneOf(str(values.type), ITEM_TYPES, 'type');
|
|
169
|
+
const status = oneOf(str(values.status), ITEM_STATUSES, 'status');
|
|
170
|
+
const severity = oneOf(str(values.severity), SEVERITIES, 'severity');
|
|
171
|
+
if (type)
|
|
172
|
+
args.type = type;
|
|
173
|
+
if (status)
|
|
174
|
+
args.status = status;
|
|
175
|
+
if (severity)
|
|
176
|
+
args.severity = severity;
|
|
177
|
+
if (str(values.tag))
|
|
178
|
+
args.tag = str(values.tag);
|
|
179
|
+
if (str(values.plan))
|
|
180
|
+
args.planId = str(values.plan);
|
|
181
|
+
if (str(values.cursor))
|
|
182
|
+
args.cursor = str(values.cursor);
|
|
183
|
+
if (str(values.limit)) {
|
|
184
|
+
const n = Number(str(values.limit));
|
|
185
|
+
if (!Number.isInteger(n) || n < 1 || n > 200)
|
|
186
|
+
throw new CliError('--limit must be a whole number from 1 to 200', EXIT.usage);
|
|
187
|
+
args.limit = n;
|
|
188
|
+
}
|
|
189
|
+
const res = await client.call('list_work_items', args);
|
|
190
|
+
if (ctx.out.json)
|
|
191
|
+
return printJson(ctx.out, res);
|
|
192
|
+
if (!res.items.length)
|
|
193
|
+
return ctx.out.print('No work items match.');
|
|
194
|
+
for (const line of table(res.items.map((w) => [
|
|
195
|
+
w.id, truncate(w.title, 50), w.type.replace('_', ' '), severityLabel(ctx.out, w.severity), w.status.replace('_', ' '),
|
|
196
|
+
[w.asset, w.area].filter(Boolean).join(' · '), w.owner ?? '', ago(w.updatedAt),
|
|
197
|
+
]), ['ID', 'TITLE', 'TYPE', 'SEVERITY', 'STATUS', 'ASSET / AREA', 'OWNER', 'UPDATED'], ctx.out))
|
|
198
|
+
ctx.out.print(line);
|
|
199
|
+
if (res.nextCursor)
|
|
200
|
+
ctx.out.print(paint(ctx.out, 'dim', `Showing ${res.items.length} of ${res.total}. Narrow with filters, raise --limit (up to 200), or get the next page with --cursor ${res.nextCursor}.`));
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
function severityLabel(out, s) {
|
|
204
|
+
return s === 'critical' || s === 'high' ? paint(out, s === 'critical' ? 'red' : 'yellow', s) : s;
|
|
205
|
+
}
|
|
206
|
+
const SEARCH_TYPES = ['product', 'asset', 'plan', 'task', 'work_item', 'spec', 'release'];
|
|
207
|
+
export const search = {
|
|
208
|
+
name: 'search',
|
|
209
|
+
summary: 'Search products, assets, plans, tasks, work items, specs and releases',
|
|
210
|
+
usage: 'codeplans search <words…> [--type <type>]… [--product <slug>] [--limit <n>]',
|
|
211
|
+
options: { type: { type: 'string', multiple: true }, product: { type: 'string' }, limit: { type: 'string' } },
|
|
212
|
+
minArgs: 1,
|
|
213
|
+
async run(ctx, args, values) {
|
|
214
|
+
const client = ctx.client();
|
|
215
|
+
const query = args.join(' ');
|
|
216
|
+
if (query.trim().length < 2)
|
|
217
|
+
throw new CliError('Search for at least 2 characters.', EXIT.usage);
|
|
218
|
+
const params = { query };
|
|
219
|
+
const types = list(values.type).map((t) => oneOf(t, SEARCH_TYPES, 'type'));
|
|
220
|
+
if (types.length)
|
|
221
|
+
params.types = types;
|
|
222
|
+
if (str(values.product))
|
|
223
|
+
params.productId = (await resolveProduct(client, str(values.product))).id;
|
|
224
|
+
if (str(values.limit))
|
|
225
|
+
params.limit = Number(str(values.limit));
|
|
226
|
+
const res = await client.call('search', params);
|
|
227
|
+
if (ctx.out.json)
|
|
228
|
+
return printJson(ctx.out, res);
|
|
229
|
+
if (!res.results.length)
|
|
230
|
+
return ctx.out.print(`Nothing matches "${query}".`);
|
|
231
|
+
for (const line of table(res.results.map((r) => [
|
|
232
|
+
paint(ctx.out, 'dim', r.type.replace('_', ' ')), truncate(r.key ? `${r.key} ${r.title}` : r.title, 60), r.status?.replace('_', ' ') ?? '', paint(ctx.out, 'dim', r.productName), r.id,
|
|
233
|
+
])))
|
|
234
|
+
ctx.out.print(line);
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
export const tools = {
|
|
238
|
+
name: 'tools',
|
|
239
|
+
summary: 'List every MCP tool the server offers (use them with codeplans call)',
|
|
240
|
+
usage: 'codeplans tools',
|
|
241
|
+
async run(ctx) {
|
|
242
|
+
const rows = await ctx.client().listTools();
|
|
243
|
+
if (ctx.out.json)
|
|
244
|
+
return printJson(ctx.out, rows);
|
|
245
|
+
for (const line of table(rows.map((t) => [t.name, truncate((t.description ?? '').split(/(?<=\.)\s/)[0], 90)])))
|
|
246
|
+
ctx.out.print(line);
|
|
247
|
+
},
|
|
248
|
+
};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { CliError, EXIT } from '../client.js';
|
|
3
|
+
import { paint, printJson } from '../output.js';
|
|
4
|
+
import { list, oneOf, resolveProduct, str } from '../cli.js';
|
|
5
|
+
const PLAN_TYPES = ['refactor', 'feature', 'improvement', 'bugfix'];
|
|
6
|
+
const ITEM_TYPES = ['feature', 'bug', 'enhancement', 'ux', 'tech_debt'];
|
|
7
|
+
const ITEM_STATUSES = ['open', 'planned', 'in_progress', 'resolved', 'wont_do'];
|
|
8
|
+
const PRIORITIES = ['low', 'medium', 'high', 'critical'];
|
|
9
|
+
/** Asset ids from names or ids, within one product. */
|
|
10
|
+
async function resolveAssets(client, productSlug, refs) {
|
|
11
|
+
if (!refs.length)
|
|
12
|
+
return [];
|
|
13
|
+
const { assets } = await client.call('get_product', { slug: productSlug });
|
|
14
|
+
return refs.map((ref) => {
|
|
15
|
+
const match = assets.find((a) => a.id === ref) ?? assets.find((a) => a.name.toLowerCase() === ref.toLowerCase());
|
|
16
|
+
if (!match)
|
|
17
|
+
throw new CliError(`No asset "${ref}" in ${productSlug}. Assets: ${assets.map((a) => a.name).join(', ') || 'none'}.`, EXIT.usage);
|
|
18
|
+
return match.id;
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
function done(ctx, data, message) {
|
|
22
|
+
if (ctx.out.json)
|
|
23
|
+
return printJson(ctx.out, data);
|
|
24
|
+
ctx.out.print(`${paint(ctx.out, 'green', '✓')} ${message}`);
|
|
25
|
+
}
|
|
26
|
+
/** Text from --description, or a file with --description-file (- reads stdin). */
|
|
27
|
+
function description(values) {
|
|
28
|
+
const file = str(values['description-file']);
|
|
29
|
+
if (file)
|
|
30
|
+
return readFileSync(file === '-' ? 0 : file, 'utf8');
|
|
31
|
+
return str(values.description);
|
|
32
|
+
}
|
|
33
|
+
export const planCreate = {
|
|
34
|
+
name: 'plan create',
|
|
35
|
+
summary: 'Create a draft code plan',
|
|
36
|
+
usage: 'codeplans plan create --product <slug> --title <title> [--type feature|refactor|improvement|bugfix] [--asset <name>]… [--item <workItemId>]… [--description <text> | --description-file <path|->] [--tag <tag>]…',
|
|
37
|
+
options: {
|
|
38
|
+
product: { type: 'string' }, title: { type: 'string' }, type: { type: 'string' }, asset: { type: 'string', multiple: true },
|
|
39
|
+
item: { type: 'string', multiple: true }, description: { type: 'string' }, 'description-file': { type: 'string' }, tag: { type: 'string', multiple: true },
|
|
40
|
+
owner: { type: 'string' }, deadline: { type: 'string' },
|
|
41
|
+
},
|
|
42
|
+
async run(ctx, _args, values) {
|
|
43
|
+
const productRef = str(values.product);
|
|
44
|
+
const title = str(values.title);
|
|
45
|
+
if (!productRef || !title)
|
|
46
|
+
throw new CliError(`Usage: ${planCreate.usage}`, EXIT.usage);
|
|
47
|
+
const client = ctx.client();
|
|
48
|
+
const p = await resolveProduct(client, productRef);
|
|
49
|
+
const plan = await client.call('create_code_plan', {
|
|
50
|
+
productId: p.id, title, type: oneOf(str(values.type), PLAN_TYPES, 'type') ?? 'feature',
|
|
51
|
+
description: description(values) ?? '', tags: list(values.tag),
|
|
52
|
+
targetAssetIds: await resolveAssets(client, p.slug, list(values.asset)), workItemIds: list(values.item),
|
|
53
|
+
...(str(values.owner) ? { ownerEmail: str(values.owner) } : {}), ...(str(values.deadline) ? { deadline: str(values.deadline) } : {}),
|
|
54
|
+
});
|
|
55
|
+
done(ctx, plan, `Created draft plan "${title}" ${paint(ctx.out, 'dim', plan.id)}`);
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
export const planActivate = {
|
|
59
|
+
name: 'plan activate',
|
|
60
|
+
summary: 'Move a draft plan to active (the review workflow may require an approval first)',
|
|
61
|
+
usage: 'codeplans plan activate <planId>',
|
|
62
|
+
minArgs: 1,
|
|
63
|
+
async run(ctx, [id]) {
|
|
64
|
+
const plan = await ctx.client().call('activate_plan', { id });
|
|
65
|
+
done(ctx, plan, `Activated "${plan.title ?? id}"`);
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
export const planComplete = {
|
|
69
|
+
name: 'plan complete',
|
|
70
|
+
summary: 'Mark a plan completed',
|
|
71
|
+
usage: 'codeplans plan complete <planId>',
|
|
72
|
+
minArgs: 1,
|
|
73
|
+
async run(ctx, [id]) {
|
|
74
|
+
const plan = await ctx.client().call('complete_plan', { id });
|
|
75
|
+
done(ctx, plan, `Completed "${plan.title ?? id}"`);
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
export const taskAdd = {
|
|
79
|
+
name: 'task add',
|
|
80
|
+
summary: 'Add a task to a plan (re-adding the same title does nothing, so scripts can re-run)',
|
|
81
|
+
usage: 'codeplans task add <planId> <title> [--priority low|medium|high|critical] [--assignee <email>] [--description <text> | --description-file <path|->]',
|
|
82
|
+
options: { priority: { type: 'string' }, assignee: { type: 'string' }, description: { type: 'string' }, 'description-file': { type: 'string' } },
|
|
83
|
+
minArgs: 2,
|
|
84
|
+
async run(ctx, [codePlanId, ...words], values) {
|
|
85
|
+
const title = words.join(' ');
|
|
86
|
+
const task = await ctx.client().call('create_task', {
|
|
87
|
+
codePlanId, title, priority: oneOf(str(values.priority), PRIORITIES, 'priority') ?? 'medium', description: description(values) ?? '',
|
|
88
|
+
...(str(values.assignee) ? { assigneeEmail: str(values.assignee) } : {}),
|
|
89
|
+
});
|
|
90
|
+
done(ctx, task, `Task "${title}" ${paint(ctx.out, 'dim', task.id)}`);
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
function taskStatus(name, status, verb, summary) {
|
|
94
|
+
return {
|
|
95
|
+
name,
|
|
96
|
+
summary,
|
|
97
|
+
usage: `codeplans ${name} <taskId>`,
|
|
98
|
+
minArgs: 1,
|
|
99
|
+
async run(ctx, [id]) {
|
|
100
|
+
const task = await ctx.client().call('update_task_status', { id, status });
|
|
101
|
+
done(ctx, task, `${verb}: ${task.title ?? id}`);
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
export const taskStart = taskStatus('task start', 'in_progress', 'Started', 'Start a task (the review workflow may require the plan to be approved)');
|
|
106
|
+
export const taskDone = taskStatus('task done', 'done', 'Done', 'Mark a task done');
|
|
107
|
+
export const taskReset = taskStatus('task reset', 'not_started', 'Reset', 'Move a task back to not started');
|
|
108
|
+
export const itemAdd = {
|
|
109
|
+
name: 'item add',
|
|
110
|
+
summary: 'File a work item',
|
|
111
|
+
usage: 'codeplans item add --product <slug> --title <title> --type feature|bug|enhancement|ux|tech_debt [--severity <s>] [--asset <name>] [--area <area>] [--description <text>] [--tag <tag>]…',
|
|
112
|
+
options: {
|
|
113
|
+
product: { type: 'string' }, title: { type: 'string' }, type: { type: 'string' }, severity: { type: 'string' }, asset: { type: 'string' },
|
|
114
|
+
area: { type: 'string' }, description: { type: 'string' }, 'description-file': { type: 'string' }, tag: { type: 'string', multiple: true }, owner: { type: 'string' },
|
|
115
|
+
},
|
|
116
|
+
async run(ctx, _args, values) {
|
|
117
|
+
const productRef = str(values.product);
|
|
118
|
+
const title = str(values.title);
|
|
119
|
+
const type = oneOf(str(values.type), ITEM_TYPES, 'type');
|
|
120
|
+
if (!productRef || !title || !type)
|
|
121
|
+
throw new CliError(`Usage: ${itemAdd.usage}`, EXIT.usage);
|
|
122
|
+
const client = ctx.client();
|
|
123
|
+
const p = await resolveProduct(client, productRef);
|
|
124
|
+
const [assetId] = await resolveAssets(client, p.slug, str(values.asset) ? [str(values.asset)] : []);
|
|
125
|
+
const item = await client.call('create_work_item', {
|
|
126
|
+
productId: p.id, title, type, severity: oneOf(str(values.severity), PRIORITIES, 'severity') ?? 'medium',
|
|
127
|
+
description: description(values) ?? '', tags: list(values.tag),
|
|
128
|
+
...(assetId ? { assetId } : {}), ...(str(values.area) ? { area: str(values.area) } : {}), ...(str(values.owner) ? { ownerEmail: str(values.owner) } : {}),
|
|
129
|
+
});
|
|
130
|
+
done(ctx, item, `Filed "${title}" ${paint(ctx.out, 'dim', item.id)}`);
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
export const itemStatus = {
|
|
134
|
+
name: 'item status',
|
|
135
|
+
summary: "Set a work item's status",
|
|
136
|
+
usage: 'codeplans item status <itemId> open|planned|in_progress|resolved|wont_do',
|
|
137
|
+
minArgs: 2,
|
|
138
|
+
async run(ctx, [id, status]) {
|
|
139
|
+
const item = await ctx.client().call('update_work_item_status', { id, status: oneOf(status, ITEM_STATUSES, 'status') });
|
|
140
|
+
done(ctx, item, `${item.title ?? id} is now ${status.replace('_', ' ')}`);
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
/** "k=v": the value is parsed as JSON when it parses (numbers, booleans, arrays), else kept as a string. */
|
|
144
|
+
export function parseArg(pair) {
|
|
145
|
+
const eq = pair.indexOf('=');
|
|
146
|
+
if (eq < 1)
|
|
147
|
+
throw new CliError(`--arg takes key=value, got "${pair}"`, EXIT.usage);
|
|
148
|
+
const key = pair.slice(0, eq);
|
|
149
|
+
const raw = pair.slice(eq + 1);
|
|
150
|
+
try {
|
|
151
|
+
return [key, JSON.parse(raw)];
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return [key, raw];
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
export const call = {
|
|
158
|
+
name: 'call',
|
|
159
|
+
summary: 'Call any MCP tool directly, for anything without its own command',
|
|
160
|
+
usage: 'codeplans call <tool> [--arg key=value]… [--input <file.json|->]',
|
|
161
|
+
details: 'Values given with --arg are parsed as JSON when they can be (e.g. --arg limit=5 --arg tags=\'["a"]\'), otherwise taken as text.\nThe result is printed as JSON. List the tools and their descriptions with: codeplans tools',
|
|
162
|
+
options: { arg: { type: 'string', multiple: true }, input: { type: 'string' } },
|
|
163
|
+
minArgs: 1,
|
|
164
|
+
async run(ctx, [tool], values) {
|
|
165
|
+
let args = {};
|
|
166
|
+
const input = str(values.input);
|
|
167
|
+
if (input) {
|
|
168
|
+
try {
|
|
169
|
+
args = JSON.parse(readFileSync(input === '-' ? 0 : input, 'utf8'));
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
throw new CliError(`Can't read JSON from ${input}: ${err instanceof Error ? err.message : err}`, EXIT.usage);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
for (const pair of list(values.arg)) {
|
|
176
|
+
const [k, v] = parseArg(pair);
|
|
177
|
+
args[k] = v;
|
|
178
|
+
}
|
|
179
|
+
const result = await ctx.client().call(tool, args);
|
|
180
|
+
printJson(ctx.out, result);
|
|
181
|
+
},
|
|
182
|
+
};
|
package/dist/commands.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { login, logout, status } from './commands/auth.js';
|
|
2
|
+
import { me, products, product, plans, plan, items, search, tools } from './commands/read.js';
|
|
3
|
+
import { planCreate, planActivate, planComplete, taskAdd, taskStart, taskDone, taskReset, itemAdd, itemStatus, call } from './commands/write.js';
|
|
4
|
+
import { apply } from './commands/apply.js';
|
|
5
|
+
/** Every command, in the order help lists them. */
|
|
6
|
+
export const COMMANDS = [
|
|
7
|
+
login, logout, status,
|
|
8
|
+
me, products, product,
|
|
9
|
+
plans, plan, planCreate, planActivate, planComplete,
|
|
10
|
+
taskAdd, taskStart, taskDone, taskReset,
|
|
11
|
+
items, itemAdd, itemStatus,
|
|
12
|
+
search, apply, tools, call,
|
|
13
|
+
];
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Connection profiles in ~/.config/codeplans/config.json (or $XDG_CONFIG_HOME),
|
|
2
|
+
// written with mode 0600. CODEPLANS_URL / CODEPLANS_API_KEY / CODEPLANS_PROFILE
|
|
3
|
+
// override the file, so CI needs no file at all.
|
|
4
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { CliError, EXIT } from './client.js';
|
|
8
|
+
export function configPath(env = process.env) {
|
|
9
|
+
const base = env.XDG_CONFIG_HOME?.trim() || path.join(homedir(), '.config');
|
|
10
|
+
return path.join(base, 'codeplans', 'config.json');
|
|
11
|
+
}
|
|
12
|
+
export function readConfig(file = configPath()) {
|
|
13
|
+
if (!existsSync(file))
|
|
14
|
+
return { profiles: {} };
|
|
15
|
+
try {
|
|
16
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
17
|
+
return { current: parsed.current, profiles: parsed.profiles ?? {} };
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
throw new CliError(`Can't read ${file}. Fix or delete it, then run codeplans login.`, EXIT.usage);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function writeConfig(config, file = configPath()) {
|
|
24
|
+
mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
25
|
+
writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
|
26
|
+
chmodSync(file, 0o600);
|
|
27
|
+
}
|
|
28
|
+
/** Where to connect: environment variables first, then the chosen (or current) profile. */
|
|
29
|
+
export function resolveConnection(opts, env = process.env, file = configPath(env)) {
|
|
30
|
+
const profileName = opts.profile ?? env.CODEPLANS_PROFILE ?? undefined;
|
|
31
|
+
if (env.CODEPLANS_URL && env.CODEPLANS_API_KEY && !opts.profile) {
|
|
32
|
+
return { url: normalizeUrl(env.CODEPLANS_URL), key: env.CODEPLANS_API_KEY, profile: null, source: 'env' };
|
|
33
|
+
}
|
|
34
|
+
const config = readConfig(file);
|
|
35
|
+
const name = profileName ?? config.current ?? 'default';
|
|
36
|
+
const profile = config.profiles[name];
|
|
37
|
+
if (!profile) {
|
|
38
|
+
throw new CliError(Object.keys(config.profiles).length
|
|
39
|
+
? `No profile named "${name}". Profiles: ${Object.keys(config.profiles).join(', ')}.`
|
|
40
|
+
: 'Not connected. Run: codeplans login --url https://your-codeplans --key cpk_… (or set CODEPLANS_URL and CODEPLANS_API_KEY).', EXIT.auth);
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
url: normalizeUrl(env.CODEPLANS_URL ?? profile.url),
|
|
44
|
+
key: env.CODEPLANS_API_KEY ?? profile.key,
|
|
45
|
+
profile: name,
|
|
46
|
+
source: 'profile',
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** HTTPS only, except for local development. */
|
|
50
|
+
export function normalizeUrl(raw) {
|
|
51
|
+
let url;
|
|
52
|
+
try {
|
|
53
|
+
url = new URL(raw.trim());
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
throw new CliError(`"${raw}" isn't a URL. Use the address you open CodePlans at, e.g. https://acme.codeplans.app`, EXIT.usage);
|
|
57
|
+
}
|
|
58
|
+
const local = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
|
|
59
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && local)) {
|
|
60
|
+
throw new CliError('Use an https:// URL (http:// is allowed only for localhost).', EXIT.usage);
|
|
61
|
+
}
|
|
62
|
+
return url.origin + url.pathname.replace(/\/+$/, '');
|
|
63
|
+
}
|
|
64
|
+
export function maskKey(key) {
|
|
65
|
+
return key.length <= 10 ? '••••' : `${key.slice(0, 6)}…${key.slice(-4)}`;
|
|
66
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { run } from './cli.js';
|
|
3
|
+
import { COMMANDS } from './commands.js';
|
|
4
|
+
// Output piped into head, grep -m and the like: stop quietly when the reader goes away.
|
|
5
|
+
for (const stream of [process.stdout, process.stderr]) {
|
|
6
|
+
stream.on('error', (err) => { if (err.code === 'EPIPE')
|
|
7
|
+
process.exit(0); throw err; });
|
|
8
|
+
}
|
|
9
|
+
run(process.argv.slice(2), COMMANDS).then((code) => { process.exitCode = code; });
|
package/dist/output.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export function createOut(json, stream = process.stdout, err = process.stderr, env = process.env) {
|
|
2
|
+
const color = !json && !!stream.isTTY && !env.NO_COLOR && env.TERM !== 'dumb';
|
|
3
|
+
return {
|
|
4
|
+
json,
|
|
5
|
+
color,
|
|
6
|
+
print: (line = '') => { stream.write(`${line}\n`); },
|
|
7
|
+
error: (line) => { err.write(`${line}\n`); },
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
const codes = { bold: [1, 22], dim: [2, 22], red: [31, 39], green: [32, 39], yellow: [33, 39], cyan: [36, 39] };
|
|
11
|
+
export function paint(out, style, text) {
|
|
12
|
+
if (!out.color)
|
|
13
|
+
return text;
|
|
14
|
+
const [open, close] = codes[style];
|
|
15
|
+
return `\u001b[${open}m${text}\u001b[${close}m`;
|
|
16
|
+
}
|
|
17
|
+
const ANSI = /\u001b\[[0-9;]*m/g;
|
|
18
|
+
const visible = (s) => s.replace(ANSI, '').length;
|
|
19
|
+
/** Columns padded to their widest cell; the last column isn't padded. */
|
|
20
|
+
export function table(rows, headers, out) {
|
|
21
|
+
const all = headers ? [headers.map((h) => (out ? paint(out, 'dim', h) : h)), ...rows] : rows;
|
|
22
|
+
if (!all.length)
|
|
23
|
+
return [];
|
|
24
|
+
const widths = all[0].map((_, i) => Math.max(...all.map((r) => visible(r[i] ?? ''))));
|
|
25
|
+
return all.map((r) => r.map((cell, i) => (i === r.length - 1 ? cell : cell + ' '.repeat(widths[i] - visible(cell)))).join(' ').trimEnd());
|
|
26
|
+
}
|
|
27
|
+
export function truncate(text, max) {
|
|
28
|
+
return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
|
|
29
|
+
}
|
|
30
|
+
export function ago(iso, now = Date.now()) {
|
|
31
|
+
if (!iso)
|
|
32
|
+
return '';
|
|
33
|
+
const t = new Date(iso).getTime();
|
|
34
|
+
if (Number.isNaN(t))
|
|
35
|
+
return '';
|
|
36
|
+
const s = Math.round((now - t) / 1000);
|
|
37
|
+
if (s < 0)
|
|
38
|
+
return new Date(iso).toISOString().slice(0, 10);
|
|
39
|
+
if (s < 60)
|
|
40
|
+
return 'just now';
|
|
41
|
+
const units = [[60, 'm'], [3600, 'h'], [86400, 'd'], [604800, 'w']];
|
|
42
|
+
let label = '';
|
|
43
|
+
for (const [size, unit] of units)
|
|
44
|
+
if (s >= size)
|
|
45
|
+
label = `${Math.floor(s / size)}${unit} ago`;
|
|
46
|
+
return s >= 2592000 ? new Date(iso).toISOString().slice(0, 10) : label;
|
|
47
|
+
}
|
|
48
|
+
export function printJson(out, data) {
|
|
49
|
+
out.print(JSON.stringify(data, null, 2));
|
|
50
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@codeplans/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.7.7",
|
|
4
|
+
"description": "Command-line tool for CodePlans: script workspace setups, work with code plans, tasks and work items from your terminal.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"codeplans": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=20"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc -p tsconfig.json && chmod +x dist/index.js",
|
|
20
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
21
|
+
"prepublishOnly": "npm run build"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/SylonZero/CodePlans.git",
|
|
26
|
+
"directory": "cli"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/SylonZero/CodePlans/blob/master/docs/guides/cli.md",
|
|
29
|
+
"keywords": ["codeplans", "cli", "mcp", "planning", "architecture"]
|
|
30
|
+
}
|