@motionode/cli 0.1.2 → 0.2.1

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.
@@ -0,0 +1,116 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * C1-031: file work and keep project notes from the terminal, as the connected person (same machine token as the rest
5
+ * of the CLI, or MOTIONODE_TOKEN). The project is --project <uuid or exact name>, or MOTIONODE_PROJECT.
6
+ *
7
+ * motionode deliverables --project P
8
+ * motionode work-item create --project P --title T [--description D | --description-file F]
9
+ * [--deliverable R2-1 | --new-deliverable "Name"] [--criteria "a | b"] [--confirmed-new]
10
+ * motionode work-item update <code> --project P [--title T] [--description D | --description-file F] (C1-032)
11
+ * motionode notes list --project P
12
+ * motionode notes get <uuid> --project P [--html]
13
+ * motionode notes create --project P --file F.md [--deliverable R | --ticket T] [--share a@x,b@y] [--category C]
14
+ * motionode notes update <uuid> --project P --file F.md
15
+ */
16
+
17
+ const fs = require('fs');
18
+ const { agentClient } = require('./agent_api');
19
+
20
+ function projectOf(args) {
21
+ const p = (typeof args.project === 'string' && args.project) || process.env.MOTIONODE_PROJECT || '';
22
+ if (!p) throw new Error('Say which project: --project <uuid or name> (or set MOTIONODE_PROJECT).');
23
+ return encodeURIComponent(p);
24
+ }
25
+
26
+ function readText(file) {
27
+ if (typeof file !== 'string' || !file) throw new Error('Give the content with --file <path>.');
28
+ return fs.readFileSync(file, 'utf8');
29
+ }
30
+
31
+ const list = (value) => (typeof value === 'string' ? value.split('|').map((s) => s.trim()).filter(Boolean) : []);
32
+
33
+ async function deliverables(config, args, { api = agentClient(config), out = console.log } = {}) {
34
+ const res = await api.get(`/project/${projectOf(args)}/deliverables`);
35
+ for (const d of res.deliverables || []) out(`${d.displayId || '-'}\t${d.title}${Number(d.isInterrupt) ? ' (Interrupt)' : ''}\t${d.uuid}`);
36
+ return res;
37
+ }
38
+
39
+ async function createWorkItem(config, args, { api = agentClient(config), out = console.log } = {}) {
40
+ if (typeof args.title !== 'string' || !args.title.trim()) throw new Error('Give the work item a --title.');
41
+ const description = typeof args['description-file'] === 'string' ? readText(args['description-file']) : (typeof args.description === 'string' ? args.description : '');
42
+ try {
43
+ const res = await api.post(`/project/${projectOf(args)}/work_items`, {
44
+ title: args.title,
45
+ description,
46
+ deliverable: typeof args.deliverable === 'string' ? args.deliverable : null,
47
+ newDeliverable: typeof args['new-deliverable'] === 'string' ? args['new-deliverable'] : null,
48
+ acceptanceCriteria: list(args.criteria),
49
+ confirmedNew: args['confirmed-new'] === true,
50
+ });
51
+ out(`✓ ${res.workItem.displayId || res.workItem.uuid} "${res.workItem.title}" in ${res.deliverable.displayId || ''} ${res.deliverable.title}${res.deliverable.created ? ' (new deliverable)' : ''} — in setup, nothing started`);
52
+ for (const s of res.similar || []) out(` similar open work: ${s.displayId || s.uuid} "${s.title}"${s.why ? ` (${s.why})` : ''}`);
53
+ return res;
54
+ } catch (err) {
55
+ // 409: open work already has this title -- the API names it.
56
+ if (err.status === 409) out(' (pass --confirmed-new if this is genuinely separate)');
57
+ throw err;
58
+ }
59
+ }
60
+
61
+ /** C1-032: change a work item's title and/or description, as the connected person (their edit rights on the project). */
62
+ async function updateWorkItem(config, args, { api = agentClient(config), out = console.log } = {}) {
63
+ const ref = args._[1];
64
+ if (!ref) throw new Error('Which work item? motionode work-item update <code> --title T');
65
+ const patch = {};
66
+ if (typeof args.title === 'string') patch.title = args.title;
67
+ if (typeof args['description-file'] === 'string') patch.description = readText(args['description-file']);
68
+ else if (typeof args.description === 'string') patch.description = args.description;
69
+ if (typeof args.status === 'string') patch.status = args.status;
70
+ if (!Object.keys(patch).length) throw new Error('Say what to change: --title and/or --description (or --description-file).');
71
+ const res = await api.patch(`/project/${projectOf(args)}/work_items/${encodeURIComponent(ref)}`, patch);
72
+ out(res.changed && res.changed.length
73
+ ? `✓ ${res.workItem.displayId || res.workItem.uuid} "${res.workItem.title}": changed ${res.changed.join(' and ')}`
74
+ : `= ${res.workItem.displayId || res.workItem.uuid} "${res.workItem.title}": nothing to change`);
75
+ return res;
76
+ }
77
+
78
+ async function notes(config, args, { api = agentClient(config), out = console.log } = {}) {
79
+ const sub = args._[0] || 'list';
80
+ const project = projectOf(args);
81
+ if (sub === 'list') {
82
+ const res = await api.get(`/project/${project}/notes`);
83
+ for (const n of res.notes || []) out(`${n.uuid}\t${n.title || '(untitled)'}\t${n.entityType}${n.category ? `/${n.category}` : ''}\t${n.chars} chars`);
84
+ return res;
85
+ }
86
+ const uuid = args._[1];
87
+ if (sub === 'get') {
88
+ if (!uuid) throw new Error('Which note? motionode notes get <uuid>');
89
+ const res = await api.get(`/project/${project}/notes/${encodeURIComponent(uuid)}`);
90
+ out(args.html ? (res.note.html || '') : (res.note.text || ''));
91
+ return res;
92
+ }
93
+ if (sub === 'create') {
94
+ const res = await api.post(`/project/${project}/notes`, {
95
+ markdown: readText(args.file),
96
+ category: typeof args.category === 'string' ? args.category : null,
97
+ deliverable: typeof args.deliverable === 'string' ? args.deliverable : null,
98
+ ticket: typeof args.ticket === 'string' ? args.ticket : null,
99
+ shareWith: typeof args.share === 'string' ? args.share.split(',').map((s) => s.trim()).filter(Boolean) : [],
100
+ });
101
+ out(`✓ note ${res.note.uuid} "${res.note.title || ''}" (${res.note.entityType})`);
102
+ return res;
103
+ }
104
+ if (sub === 'update') {
105
+ if (!uuid) throw new Error('Which note? motionode notes update <uuid> --file F.md');
106
+ const res = await api.patch(`/project/${project}/notes/${encodeURIComponent(uuid)}`, {
107
+ markdown: readText(args.file),
108
+ category: typeof args.category === 'string' ? args.category : null,
109
+ });
110
+ out(`✓ updated note ${res.note.uuid} "${res.note.title || ''}"`);
111
+ return res;
112
+ }
113
+ throw new Error(`Unknown: motionode notes ${sub} (list | get | create | update)`);
114
+ }
115
+
116
+ module.exports = { deliverables, createWorkItem, updateWorkItem, notes, _internals: { projectOf, list } };
@@ -0,0 +1,58 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * `motionode signals hook <event>`: run by Claude Code hooks (no model, no network;
5
+ * reads the local inbox that `motionode signals watch` keeps). It puts pending
6
+ * Motionode signals in front of the agent at a safe point:
7
+ * PostToolUse / UserPromptSubmit → add them to the agent's context;
8
+ * Stop → refuse to finish while a signal that needs action is unacknowledged
9
+ * (never twice in a row: Claude Code's stop_hook_active).
10
+ * The agent clears a signal with the MCP tool ack_signal.
11
+ */
12
+
13
+ const Inbox = require('./inbox');
14
+
15
+ function readStdin() {
16
+ return new Promise((resolve) => {
17
+ if (process.stdin.isTTY) return resolve({});
18
+ let data = '';
19
+ process.stdin.setEncoding('utf8');
20
+ process.stdin.on('data', (c) => { data += c; });
21
+ process.stdin.on('end', () => { try { resolve(JSON.parse(data || '{}')); } catch (_e) { resolve({}); } });
22
+ setTimeout(() => resolve({}), 1500);
23
+ });
24
+ }
25
+
26
+ function describe(signals) {
27
+ const lines = signals.slice(0, 8).map((s) => `- [#${s.id} · ${s.kind}${s.requiresAction ? ' · needs action' : ''} · ticket ${s.ticketRef || '?'}] ${String(s.summary || '').replace(/\s+/g, ' ').slice(0, 300)}`);
28
+ const more = signals.length > 8 ? `\n…and ${signals.length - 8} more (call get_signals).` : '';
29
+ return `Motionode signals for this work (acknowledge each with the motionode ack_signal tool; set incorporated=true only once your work reflects it):\n${lines.join('\n')}${more}`;
30
+ }
31
+
32
+ async function run(event) {
33
+ const input = await readStdin();
34
+ const dir = input.cwd || process.cwd();
35
+ const pending = Inbox.read(dir);
36
+ if (event === 'Stop') {
37
+ if (input.stop_hook_active) return; // already blocked once; let the agent stop
38
+ const critical = pending.filter((s) => s.requiresAction);
39
+ if (!critical.length) return;
40
+ process.stdout.write(JSON.stringify({
41
+ decision: 'block',
42
+ reason: `${describe(critical)}\nBefore finishing: handle these, or acknowledge them with ack_signal and say why they don't apply.`,
43
+ }));
44
+ return;
45
+ }
46
+ if (!pending.length) return;
47
+ process.stdout.write(JSON.stringify({
48
+ hookSpecificOutput: { hookEventName: event, additionalContext: describe(pending) },
49
+ }));
50
+ }
51
+
52
+ function printPending() {
53
+ const pending = Inbox.read(process.cwd());
54
+ if (!pending.length) { console.log('No Motionode signals waiting for this repository.'); return; }
55
+ console.log(describe(pending));
56
+ }
57
+
58
+ module.exports = { run, printPending, describe };
@@ -0,0 +1,168 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * `motionode signals watch`: one long-lived connection to Motionode's signal
5
+ * stream (Server-Sent Events). Each pushed signal is written to the local inbox
6
+ * at once; no model is involved, so this costs no AI credits. Reconnects with a
7
+ * growing pause when the connection drops.
8
+ *
9
+ * Two robustness details, since this runs unattended for hours:
10
+ * - the server heartbeats every 20s, but a silent intermediary (a proxy or NAT
11
+ * that drops the stream without a FIN/RST) can leave the socket open and
12
+ * looking "connected" while nothing ever arrives again. An idle timeout
13
+ * (IDLE_TIMEOUT_MS, reset on every chunk received, heartbeats included)
14
+ * aborts and reconnects instead of hanging forever.
15
+ * - a single 401 could be a transient auth hiccup, not an actual revocation.
16
+ * watch() only gives up for good after FATAL_401_STREAK consecutive 401s;
17
+ * anything else in between (a real event, or a non-401 error) resets the count.
18
+ */
19
+
20
+ const pkg = require('../package.json');
21
+ const Inbox = require('./inbox');
22
+
23
+ const IDLE_TIMEOUT_MS = 60000;
24
+ const FATAL_401_STREAK = 2;
25
+
26
+ function parseEvents(buffer) {
27
+ const events = [];
28
+ let rest = buffer;
29
+ let idx;
30
+ while ((idx = rest.indexOf('\n\n')) >= 0) {
31
+ const block = rest.slice(0, idx);
32
+ rest = rest.slice(idx + 2);
33
+ let event = 'message';
34
+ const data = [];
35
+ for (const line of block.split('\n')) {
36
+ if (line.startsWith(':')) continue;
37
+ if (line.startsWith('event:')) event = line.slice(6).trim();
38
+ else if (line.startsWith('data:')) data.push(line.slice(5).trimStart());
39
+ }
40
+ if (data.length) events.push({ event, data: data.join('\n') });
41
+ }
42
+ return { events, rest };
43
+ }
44
+
45
+ async function watchOnce({ base, token, dir, onSignal, onStatus = null }) {
46
+ const controller = new AbortController();
47
+ let idleTimer = null;
48
+ const armIdleTimer = () => {
49
+ clearTimeout(idleTimer);
50
+ idleTimer = setTimeout(() => controller.abort(), IDLE_TIMEOUT_MS);
51
+ };
52
+
53
+ armIdleTimer();
54
+ let resp;
55
+ try {
56
+ resp = await fetch(`${base}/api/v1/agent/signals/stream`, {
57
+ // Same agent name as the MCP server, so the watch and the tools are ONE session (one holder of its claims).
58
+ headers: { Authorization: `Bearer ${token}`, Accept: 'text/event-stream', 'User-Agent': `motionode-cli/${pkg.version}`, ...require('./agent_api').agentHeaders() },
59
+ signal: controller.signal,
60
+ });
61
+ } catch (err) {
62
+ clearTimeout(idleTimer);
63
+ if (err.name === 'AbortError') throw new Error(`No data from Motionode for ${Math.round(IDLE_TIMEOUT_MS / 1000)}s; reconnecting.`);
64
+ throw err;
65
+ }
66
+ if (resp.status === 401) { clearTimeout(idleTimer); throw Object.assign(new Error('Not connected or token revoked.'), { status: 401 }); }
67
+ if (!resp.ok || !resp.body) { clearTimeout(idleTimer); throw new Error(`Motionode answered ${resp.status}.`); }
68
+
69
+ const reader = resp.body.getReader();
70
+ const decoder = new TextDecoder();
71
+ let buffer = '';
72
+ try {
73
+ for (;;) {
74
+ let value, done;
75
+ try {
76
+ ({ value, done } = await reader.read());
77
+ } catch (err) {
78
+ if (err.name === 'AbortError') throw new Error(`No data from Motionode for ${Math.round(IDLE_TIMEOUT_MS / 1000)}s; reconnecting.`);
79
+ throw err;
80
+ }
81
+ if (done) return;
82
+ armIdleTimer(); // bytes arrived (an event or a heartbeat ping) -- the connection is alive
83
+ buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n');
84
+ const parsed = parseEvents(buffer);
85
+ buffer = parsed.rest;
86
+ for (const ev of parsed.events) {
87
+ // The stream says when it is connected and which work it watches ("ready", then "scope" when that changes):
88
+ // shown, so a quiet watch reads as connected and waiting, never as stopped.
89
+ if ((ev.event === 'ready' || ev.event === 'scope') && onStatus) {
90
+ let info = null;
91
+ try { info = JSON.parse(ev.data); } catch (_e) { info = null; }
92
+ onStatus(ev.event, info || {});
93
+ continue;
94
+ }
95
+ // Motionode couldn't read the work or its signals (it retries): said, so silence never means "nothing".
96
+ if ((ev.event === 'error' || ev.event === 'recovered') && onStatus) {
97
+ let info = null;
98
+ try { info = JSON.parse(ev.data); } catch (_e) { info = null; }
99
+ onStatus(ev.event, info || {});
100
+ continue;
101
+ }
102
+ if (ev.event !== 'signal') continue;
103
+ let signal = null;
104
+ try { signal = JSON.parse(ev.data); } catch (_e) { continue; }
105
+ if (signal && Inbox.add(dir, signal) && onSignal) onSignal(signal);
106
+ }
107
+ }
108
+ } finally {
109
+ clearTimeout(idleTimer);
110
+ }
111
+ }
112
+
113
+ async function watch(config, { dir = process.cwd(), quiet = false } = {}) {
114
+ const base = String(config.apiUrl || '').replace(/\/+$/, '');
115
+ const token = process.env.MOTIONODE_TOKEN || config.token;
116
+ if (!token) {
117
+ console.error('Not connected. Run "motionode connect" first.');
118
+ process.exitCode = 1;
119
+ return;
120
+ }
121
+ Inbox.ensureExcluded(dir);
122
+ // The inbox exists from the start: an empty one means nothing has been sent yet, not that the watch is broken.
123
+ if (!Inbox.read(dir).length) Inbox.write(dir, []);
124
+ console.log(`Watching Motionode signals for this repository → ${Inbox.inboxPath(dir)}`);
125
+ let pause = 2000;
126
+ let consecutive401s = 0;
127
+ for (;;) {
128
+ try {
129
+ await watchOnce({
130
+ base,
131
+ token,
132
+ dir,
133
+ onStatus: (event, info) => {
134
+ pause = 2000;
135
+ consecutive401s = 0;
136
+ const n = Number(info.ticketCount) || 0;
137
+ if (event === 'error') { console.error(`[${new Date().toLocaleTimeString()}] ⚠ ${info.error || "Motionode couldn't read your work; retrying."}`); return; }
138
+ if (event === 'recovered') { if (!quiet) console.log(`[${new Date().toLocaleTimeString()}] Reading signals again: watching ${n} ticket${n === 1 ? '' : 's'}.`); return; }
139
+ if (!quiet) console.log(`[${new Date().toLocaleTimeString()}] ${event === 'ready' ? 'Connected' : 'Your work changed'}: watching ${n} ticket${n === 1 ? '' : 's'}.`);
140
+ },
141
+ onSignal: (s) => {
142
+ pause = 2000;
143
+ consecutive401s = 0;
144
+ if (!quiet) console.log(`[${new Date().toLocaleTimeString()}] ${s.requiresAction ? '⚠ ' : ''}${s.kind}: ${String(s.summary || '').split('\n')[0].slice(0, 160)}`);
145
+ if (s.requiresAction) process.stdout.write('\u0007');
146
+ },
147
+ });
148
+ consecutive401s = 0; // a clean connection (even with no signals) means the token is fine
149
+ } catch (err) {
150
+ if (err.status === 401) {
151
+ consecutive401s += 1;
152
+ if (consecutive401s >= FATAL_401_STREAK) {
153
+ console.error('Not connected or token revoked. Run "motionode connect".');
154
+ process.exitCode = 1;
155
+ return;
156
+ }
157
+ if (!quiet) console.error(`Motionode answered 401 (attempt ${consecutive401s}/${FATAL_401_STREAK}); retrying in case it's transient.`);
158
+ } else {
159
+ consecutive401s = 0;
160
+ if (!quiet) console.error(`Signal stream interrupted (${err.message}); reconnecting in ${Math.round(pause / 1000)}s`);
161
+ }
162
+ }
163
+ await new Promise((r) => setTimeout(r, pause));
164
+ pause = Math.min(pause * 2, 60000);
165
+ }
166
+ }
167
+
168
+ module.exports = { watch, parseEvents };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motionode/cli",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "description": "Connect your local repositories to Motionode: work tickets in Cursor, VS Code or any editor while Motionode keeps the work tied to the ticket, its git branches, verification and delivery.",
5
5
  "license": "UNLICENSED",
6
6
  "bin": {