Skip to content

Commit 7ccb38b

Browse files
Add productive run command for JS/TS script execution (#173)
* Add `productive run` command for JS/TS script execution Adds a new `productive run` command (alias: `productive script`) that executes a JavaScript or TypeScript script with a pre-configured Productive SDK client injected automatically. - Reads credentials from the usual sources (keychain → config file → env vars → CLI flags) - Writes a temporary bootstrap wrapper (.mjs) that imports the SDK via an absolute file:// URL — no extra npm install required in the user's project - Supports two authoring patterns: - Pattern A: `export default async function ({ client, output, args }) {}` - Pattern B: globals (`productive`, `output`, `args`) - TypeScript files (.ts, .mts) use Node.js built-in type stripping via --experimental-strip-types + --experimental-transform-types — no tsx/ts-node needed - Forwards the subprocess exit code; cleans up the temp wrapper after execution New packages/cli/src/script/ subpackage: - types.ts: ScriptContext, ScriptOutput, ScriptSpinner interfaces - output.ts: ScriptOutput implementation (table, json, csv, logging, spinner) - wrapper.ts: bootstrap wrapper generator - index.ts: public API for @studiometa/productive-cli/script subpath export New packages/cli/src/commands/run/: - handlers.ts: scriptRun, isTypeScriptFile, waitForProcess (exported for testability) - command.ts: handleRunCommand entry point - help.ts, index.ts Updated: cli.ts, package.json, vite.config.ts, SKILL.md, CHANGELOG.md Co-authored-by: Claude <claude@anthropic.com> * Add parsed `flags` to ScriptContext in `productive run` Scripts now receive a `flags` object alongside `args` in both pattern A and pattern B. The flag parser handles: positionals, --flag, --flag value, --flag=value, --no-flag (→ false), -f, -f value, repeated flags (→ array), negative numbers as values, and the -- end-of-flags separator. - `packages/cli/src/script/args.ts` — new `parseScriptArgs()` function - `packages/cli/src/script/args.test.ts` — 24 tests for the parser - `ScriptContext` gains a `flags: ParsedFlags` field - Wrapper bootstrap uses `parseScriptArgs` and passes `flags` to the default export and exposes it as a global for pattern B scripts - Help text updated with flags example and global listing Co-authored-by: Claude <claude@anthropic.com> * Add wrap-style `output.spinner(msg, asyncFn)` overload The handle form (`output.spinner(msg)`) is unchanged. A new overload accepts an async function: the spinner starts automatically, stops on resolution, and shows a fail message on rejection before re-throwing. The task return value is passed through so callers can `await` it directly. // before (manual) const sp = output.spinner('Loading…'); const data = await fetch(); sp.stop(); // after (wrap form) const data = await output.spinner('Loading…', () => fetch()); - 5 new tests cover the wrap form (resolve, reject, pass-through) - Help text and SKILL.md updated with the new overload Co-authored-by: Claude <claude@anthropic.com> * Enable source maps in `productive run` subprocess for accurate stack traces Add `--enable-source-maps` to the Node.js args spawned by `productive run`. This flag instructs Node to use source maps when formatting error stack traces, so lines reference the original source (TypeScript or source-mapped JS) rather than the stripped or transpiled output. With `--experimental-strip-types` Node uses SWC internally to strip types and generates inline source maps. Without `--enable-source-maps` those maps are ignored and the stack trace shows the post-strip line numbers. The flag is always added (for both .ts and .js scripts) so that .js files with external .map files also benefit. Co-authored-by: Claude <claude@anthropic.com> * Add `--dry-run` flag to `productive run` When `productive run --dry-run ./script.ts` is used, mutating HTTP requests (POST, PATCH, PUT, DELETE) are intercepted via a custom `globalThis.fetch` wrapper and recorded instead of executed. Read-only requests (GET, HEAD) still pass through so the script can fetch real data it needs. After the script completes, a summary table of the recorded calls is printed. Implementation: - `packages/cli/src/script/dry-run.ts` — new `createDryRunFetch()` and `printDryRunSummary()` helpers; 21 tests in `dry-run.test.ts` - `handlers.ts` strips `--dry-run` from rawArgs and sets `PRODUCTIVE_DRY_RUN=1` in the subprocess environment - `wrapper.ts` monkey-patches `globalThis.fetch` when the env var is set and calls `printDryRunSummary` after the script finishes - Help text and SKILL.md updated with `--dry-run` option and example Co-authored-by: Claude <claude@anthropic.com> * Add `export const meta` convention and `productive run --list` Scripts can now export a `ScriptMeta` object to declare their name, description, and usage. This metadata is picked up by the new `productive run --list` command, which scans a directory (defaults to `./scripts`) and prints an annotated index of all script files found. // In a script file: export const meta: ScriptMeta = { name: 'Weekly Report', description: 'Summarise time entries for the past week.', usage: '--from <date> --to <date>', }; // Discover scripts: productive run --list productive run --list ./automation Implementation: - `packages/cli/src/script/meta.ts` — `ScriptMeta` interface - `packages/cli/src/commands/run/list.ts` — `discoverScripts()`, `extractMetaFromSource()` (regex, no execution), `printScriptList()`, and `scriptList()` entry point; 16 tests in `list.test.ts` - `command.ts` — detects `--list` in allArgs and delegates to `scriptList`; does not forward to `scriptRun` when listing - `ScriptMeta` exported from the `@studiometa/productive-cli/script` subpath - Help text and SKILL.md updated with meta example, `--list` option, and discovery examples Co-authored-by: Claude <claude@anthropic.com> * Update CHANGELOG for productive run improvements Co-authored-by: Claude <claude@anthropic.com> * Fix script examples to use .all() instead of .list() for pagination SDK .list() returns a single-page Promise (no .toArray()); .all() returns an AsyncPaginatedIterator with .toArray() for collecting all pages. Also fix FlattenResource attribute access: use p.name, not p.attributes.name — SDK types merge id/type/attributes flat onto the top-level object. Co-authored-by: Claude <claude@anthropic.com> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Suppress Node.js ExperimentalWarning in script subprocess Set NODE_NO_WARNINGS=1 in the child process env to hide banners like: (node:XXXX) ExperimentalWarning: Transform Types is an experimental feature Only affects the spawned subprocess — the parent CLI process is unaffected. Co-authored-by: Claude <claude@anthropic.com> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add defineMeta() and createScript() helpers for script authoring Identity functions that provide full type inference without explicit annotations — similar to Vue's defineComponent pattern. import { defineMeta, createScript } from '@studiometa/productive-cli/script'; export const meta = defineMeta({ name: 'My Report' }); export default createScript(async ({ client, output, flags }) => { const tasks = await client.tasks.all().toArray(); output.table(tasks.map((t) => ({ id: t.id, title: t.title }))); }); Also updates help text and SKILL.md to show the new recommended pattern, and fixes stale p.attributes.name references (SDK types are flat). Co-authored-by: Claude <claude@anthropic.com> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add examples/ folder and fix --list to use dynamic import for meta Replace regex-based meta extraction with a single-subprocess dynamic import: spawns node with --experimental-strip-types --input-type=module, imports all discovered scripts in one pass, and reads their exported meta values. Handles defineMeta(), plain literals, and type annotations equally because they are all just module exports — no parsing heuristics needed. Falls back to {} per file on import errors (syntax errors, missing deps). Also fix productive run --list <dir> when dir is passed: the global arg parser consumed --list <dir> as options.list='<dir>' so allArgs.includes('--list') never fired. Now checks options.list first, then falls back to allArgs scan. Add 4 example scripts in packages/cli/examples/: - my-tasks.ts — open tasks assigned to current user, with --overdue-only - time-report.ts — time entries for a date range, table/csv/json output - log-time.ts — log a time entry, dry-run friendly - project-list.ts — active projects with --search and --format Co-authored-by: Claude <claude@anthropic.com> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix lint warnings in list.ts and add meta.test.ts - Rename Promise callback param resolve→finish to avoid no-shadow with node:path's resolve import - Refactor try/catch to assign result then call finish() once, eliminating the no-multiple-resolved false positive - Add ScriptMeta smoke tests (meta.ts is a pure interface) Co-authored-by: Claude <claude@anthropic.com> --------- Co-authored-by: Claude <claude@anthropic.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 29b70f7 commit 7ccb38b

34 files changed

Lines changed: 3129 additions & 3 deletions

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- **CLI**: Add `productive run` (alias `productive script`) command to execute JS/TS scripts with a pre-configured Productive SDK client injected automatically ([ff1e486], [#173])
13+
- **CLI**: Export `@studiometa/productive-cli/script` subpath with `ScriptContext`, `ScriptOutput`, `ScriptSpinner` types and `createScriptOutput()` factory for testing scripts in isolation ([ff1e486], [#173])
14+
- **CLI**: Add `@studiometa/productive-sdk` as a dependency so `productive run` can inject the SDK client into user scripts without additional installation ([ff1e486], [#173])
15+
- **CLI**: Add parsed `flags` object to `ScriptContext` — named flags like `--from 2025-01-01 --mine` are parsed and available as `flags.from` and `flags.mine` alongside positional `args` ([c432a57], [#173])
16+
- **CLI**: Add wrap-style `output.spinner(msg, asyncFn)` overload — spinner starts and auto-stops when the async task resolves or fails ([2de17c8], [#173])
17+
- **CLI**: Enable `--enable-source-maps` in `productive run` subprocess so TypeScript stack traces show original line numbers from the stripped output ([f1074c2], [#173])
18+
- **CLI**: Add `--dry-run` flag to `productive run` — intercepts mutating API calls (POST/PATCH/PUT/DELETE) via a `globalThis.fetch` wrapper, records them without executing, and prints a summary table ([4068429], [#173])
19+
- **CLI**: Add `export const meta: ScriptMeta` convention and `productive run --list [dir]` for script discovery — lists `.ts`/`.js` files in a directory with name, description, and usage from each script's `meta` export ([db674cd], [#173])
20+
21+
[ff1e486]: https://github.com/studiometa/productive-tools/commit/ff1e486
22+
[c432a57]: https://github.com/studiometa/productive-tools/commit/c432a57
23+
[2de17c8]: https://github.com/studiometa/productive-tools/commit/2de17c8
24+
[f1074c2]: https://github.com/studiometa/productive-tools/commit/f1074c2
25+
[4068429]: https://github.com/studiometa/productive-tools/commit/4068429
26+
[db674cd]: https://github.com/studiometa/productive-tools/commit/db674cd
27+
[#173]: https://github.com/studiometa/productive-tools/pull/173
28+
1029
## [0.10.11] - 2026.05.19
1130

1231
### Added

package-lock.json

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/cli/examples/README.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# productive run — examples
2+
3+
Ready-to-run script examples for `productive run`. Copy any file to your own
4+
`scripts/` directory and adjust as needed.
5+
6+
## Running an example
7+
8+
```bash
9+
# From the repo root (using the built CLI)
10+
productive run packages/cli/examples/my-tasks.ts
11+
12+
# Pass flags
13+
productive run packages/cli/examples/time-report.ts --from 2025-01-01 --to 2025-01-31
14+
15+
# Preview mutations without executing them
16+
productive run --dry-run packages/cli/examples/log-time.ts --service-id 12345 --hours 2
17+
```
18+
19+
## Examples
20+
21+
| File | Description |
22+
| -------------------------------------- | -------------------------------------------------------- |
23+
| [`my-tasks.ts`](./my-tasks.ts) | List open tasks assigned to you, with optional `--limit` |
24+
| [`time-report.ts`](./time-report.ts) | Export time entries for a date range to CSV |
25+
| [`log-time.ts`](./log-time.ts) | Log a time entry for today (or a custom date) |
26+
| [`project-list.ts`](./project-list.ts) | List active projects, optionally including archived ones |
27+
28+
## Authoring your own scripts
29+
30+
```typescript
31+
import { defineMeta, createScript } from '@studiometa/productive-cli/script';
32+
33+
export const meta = defineMeta({
34+
name: 'My Script',
35+
description: 'One-line description shown by productive run --list.',
36+
usage: '[--flag <value>]',
37+
});
38+
39+
export default createScript(async ({ client, output, flags }) => {
40+
// client → pre-configured Productive SDK client
41+
// output → output.table(), .csv(), .json(), .spinner(), .info(), …
42+
// flags → parsed flags: --from 2025-01-01 → flags.from === '2025-01-01'
43+
// args → positional args after the script path
44+
});
45+
```
46+
47+
SDK tips:
48+
49+
- `.all({ filter: { … } }).toArray()` — fetch all pages into an array
50+
- `.list({ filter: { … } })` — fetch a single page (returns `{ data, meta }`)
51+
- All resource types use flat attribute access: `task.title`, not `task.attributes.title`
52+
- `process.env.PRODUCTIVE_USER_ID` — the authenticated user's ID (injected automatically)

packages/cli/examples/log-time.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/**
2+
* Log a time entry for today (or a custom date).
3+
*
4+
* Requires a service ID — find one with: productive services list
5+
*
6+
* Usage:
7+
* productive run examples/log-time.ts --service-id <id> --hours 2
8+
* productive run examples/log-time.ts --service-id <id> --hours 1.5 --note "Fixed login bug"
9+
* productive run examples/log-time.ts --service-id <id> --hours 3 --date 2025-01-15
10+
*
11+
* # Preview without creating anything:
12+
* productive run --dry-run examples/log-time.ts --service-id <id> --hours 2
13+
*
14+
* Flags:
15+
* --service-id <id> Service (budget line) to log time against, required
16+
* --hours <n> Hours to log (decimals ok: 1.5 = 1h30), required
17+
* --note <text> Optional note for the time entry
18+
* --date <YYYY-MM-DD> Date to log for (default: today)
19+
*/
20+
21+
import { createScript, defineMeta } from '@studiometa/productive-cli/script';
22+
23+
export const meta = defineMeta({
24+
name: 'Log Time',
25+
description: 'Log a time entry for today or a custom date.',
26+
usage: '--service-id <id> --hours <n> [--note <text>] [--date <YYYY-MM-DD>]',
27+
});
28+
29+
export default createScript(async ({ client, output, flags }) => {
30+
const serviceId = flags['service-id'] as string | undefined;
31+
const hoursRaw = flags.hours as string | number | undefined;
32+
const note = flags.note as string | undefined;
33+
const date = (flags.date as string | undefined) ?? new Date().toISOString().slice(0, 10);
34+
35+
if (!serviceId) {
36+
output.error('--service-id is required. Find service IDs with: productive services list');
37+
process.exit(1);
38+
}
39+
if (hoursRaw == null) {
40+
output.error('--hours is required. Example: --hours 2 or --hours 1.5');
41+
process.exit(1);
42+
}
43+
44+
const hours = Number(hoursRaw);
45+
if (Number.isNaN(hours) || hours <= 0) {
46+
output.error(`Invalid --hours value: "${hoursRaw}". Must be a positive number.`);
47+
process.exit(1);
48+
}
49+
50+
const minutes = Math.round(hours * 60);
51+
const personId = process.env.PRODUCTIVE_USER_ID;
52+
53+
if (!personId) {
54+
output.error('PRODUCTIVE_USER_ID is not set. Run: productive whoami');
55+
process.exit(1);
56+
}
57+
58+
const spin = output.spinner(`Logging ${hours}h on service ${serviceId} for ${date}…`);
59+
60+
const entry = await client.time
61+
.create({ person_id: personId, service_id: serviceId, date, time: minutes, note })
62+
.catch((err: unknown) => {
63+
spin.fail(err instanceof Error ? err.message : String(err));
64+
process.exit(1);
65+
});
66+
67+
spin.stop(`Logged ${hours}h (entry #${entry.data.id})`);
68+
output.success(`${hours}h logged on ${date}${note ? ` — "${note}"` : ''}`);
69+
});

packages/cli/examples/my-tasks.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* List open tasks assigned to the current user.
3+
*
4+
* Usage:
5+
* productive run examples/my-tasks.ts
6+
* productive run examples/my-tasks.ts --limit 10
7+
* productive run examples/my-tasks.ts --limit 50 --overdue-only
8+
*/
9+
10+
import { createScript, defineMeta } from '@studiometa/productive-cli/script';
11+
12+
export const meta = defineMeta({
13+
name: 'My Tasks',
14+
description: 'List open tasks assigned to you.',
15+
usage: '[--limit <n>] [--overdue-only]',
16+
});
17+
18+
export default createScript(async ({ client, output, flags }) => {
19+
const limit = flags.limit ? Number(flags.limit) : 20;
20+
const overdueOnly = flags['overdue-only'] === true;
21+
22+
const tasks = await output.spinner('Fetching tasks…', () =>
23+
client.tasks
24+
.all({ filter: { assignee_id: process.env.PRODUCTIVE_USER_ID, status: '1' } })
25+
.toArray(),
26+
);
27+
28+
const today = new Date().toISOString().slice(0, 10);
29+
30+
const filtered = overdueOnly
31+
? tasks.filter((t) => t.due_date != null && t.due_date < today)
32+
: tasks;
33+
34+
if (filtered.length === 0) {
35+
output.info(overdueOnly ? 'No overdue tasks — nice work!' : 'No open tasks assigned to you.');
36+
return;
37+
}
38+
39+
output.table(
40+
filtered.slice(0, limit).map((t) => ({
41+
id: t.id,
42+
title: t.title,
43+
due: t.due_date ?? '—',
44+
overdue: t.due_date != null && t.due_date < today ? '⚠' : '',
45+
})),
46+
);
47+
48+
if (filtered.length > limit) {
49+
output.info(`Showing ${limit} of ${filtered.length} tasks. Increase --limit to see more.`);
50+
}
51+
});
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* List active projects, with optional search and format flags.
3+
*
4+
* Usage:
5+
* productive run examples/project-list.ts
6+
* productive run examples/project-list.ts --include-archived
7+
* productive run examples/project-list.ts --search "website"
8+
* productive run examples/project-list.ts --format json
9+
*
10+
* Flags:
11+
* --search <term> Filter projects by name (case-insensitive substring match)
12+
* --include-archived Include archived projects (default: active only)
13+
* --format <fmt> Output format: table (default), csv, or json
14+
*/
15+
16+
import { createScript, defineMeta } from '@studiometa/productive-cli/script';
17+
18+
export const meta = defineMeta({
19+
name: 'Project List',
20+
description: 'List active projects with optional search and format.',
21+
usage: '[--search <term>] [--include-archived] [--format table|csv|json]',
22+
});
23+
24+
export default createScript(async ({ client, output, flags }) => {
25+
const search = flags.search as string | undefined;
26+
const includeArchived = flags['include-archived'] === true;
27+
const format = (flags.format as string | undefined) ?? 'table';
28+
29+
const filter: Record<string, string> = {};
30+
if (!includeArchived) filter.status = 'active';
31+
32+
const projects = await output.spinner('Fetching projects…', () =>
33+
client.projects.all({ filter }).toArray(),
34+
);
35+
36+
const matched = search
37+
? projects.filter((p) => p.name.toLowerCase().includes(search.toLowerCase()))
38+
: projects;
39+
40+
if (matched.length === 0) {
41+
output.info(search ? `No projects matching "${search}".` : 'No projects found.');
42+
return;
43+
}
44+
45+
const rows = matched.map((p) => ({
46+
id: p.id,
47+
number: p.project_number ?? '—',
48+
name: p.name,
49+
archived: p.archived ? 'yes' : 'no',
50+
}));
51+
52+
if (format === 'csv') {
53+
output.csv(rows);
54+
} else if (format === 'json') {
55+
output.json(rows);
56+
} else {
57+
output.table(rows);
58+
output.info(`${matched.length} project${matched.length === 1 ? '' : 's'} found`);
59+
}
60+
});
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* Export time entries for a date range.
3+
*
4+
* Usage:
5+
* productive run examples/time-report.ts --from 2025-01-01 --to 2025-01-31
6+
* productive run examples/time-report.ts --from 2025-01-01 --to 2025-01-31 --format csv
7+
* productive run examples/time-report.ts --from 2025-01-01 --to 2025-01-31 --mine
8+
*
9+
* Flags:
10+
* --from <date> Start date (YYYY-MM-DD), required
11+
* --to <date> End date (YYYY-MM-DD), required
12+
* --mine Filter to your own entries only (default: all team entries)
13+
* --format <fmt> Output format: table (default), csv, or json
14+
*/
15+
16+
import { createScript, defineMeta } from '@studiometa/productive-cli/script';
17+
18+
export const meta = defineMeta({
19+
name: 'Time Report',
20+
description: 'Export time entries for a date range.',
21+
usage: '--from <YYYY-MM-DD> --to <YYYY-MM-DD> [--mine] [--format table|csv|json]',
22+
});
23+
24+
export default createScript(async ({ client, output, flags }) => {
25+
const from = flags.from as string | undefined;
26+
const to = flags.to as string | undefined;
27+
const format = (flags.format as string | undefined) ?? 'table';
28+
29+
if (!from || !to) {
30+
output.error('--from and --to are required. Example: --from 2025-01-01 --to 2025-01-31');
31+
process.exit(1);
32+
}
33+
34+
const filter: Record<string, string> = {
35+
after: from,
36+
before: to,
37+
};
38+
39+
if (flags.mine) {
40+
filter.person_id = process.env.PRODUCTIVE_USER_ID ?? '';
41+
}
42+
43+
const entries = await output.spinner(`Fetching time entries from ${from} to ${to}…`, () =>
44+
client.time.all({ filter }).toArray(),
45+
);
46+
47+
if (entries.length === 0) {
48+
output.info('No time entries found for the selected period.');
49+
return;
50+
}
51+
52+
// Productive stores time in minutes — convert to decimal hours for readability
53+
const rows = entries.map((e) => ({
54+
id: e.id,
55+
date: e.date,
56+
hours: (e.time / 60).toFixed(2),
57+
note: e.note ?? '',
58+
}));
59+
60+
const totalMinutes = entries.reduce((sum, e) => sum + e.time, 0);
61+
const totalHours = (totalMinutes / 60).toFixed(2);
62+
63+
if (format === 'csv') {
64+
output.csv(rows);
65+
} else if (format === 'json') {
66+
output.json(rows);
67+
} else {
68+
output.table(rows);
69+
output.info(`Total: ${totalHours}h across ${entries.length} entries`);
70+
}
71+
});

packages/cli/package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@
3131
".": {
3232
"types": "./dist/index.d.ts",
3333
"import": "./dist/index.js"
34+
},
35+
"./script": {
36+
"types": "./dist/script.d.ts",
37+
"import": "./dist/script.js"
3438
}
3539
},
3640
"publishConfig": {
@@ -45,7 +49,8 @@
4549
},
4650
"dependencies": {
4751
"@studiometa/productive-api": "*",
48-
"@studiometa/productive-core": "*"
52+
"@studiometa/productive-core": "*",
53+
"@studiometa/productive-sdk": "*"
4954
},
5055
"devDependencies": {
5156
"@vitest/coverage-v8": "^4.1.0-beta.5",

0 commit comments

Comments
 (0)