maiass 5.13.0 → 5.14.2

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,54 @@
1
+ // Pure argv parsing helpers shared between maiass.mjs and unit tests.
2
+ //
3
+ // Kept dependency-free and side-effect-free so it can be imported in a unit
4
+ // test without triggering maiass.mjs's top-level CLI bootstrap.
5
+
6
+ /**
7
+ * Extract the -m / --message commit-message value from argv (MAI-51 parity).
8
+ *
9
+ * Supported forms (single value only — NOT git-style repeated -m):
10
+ * -m <value>
11
+ * --message <value>
12
+ * --message=<value>
13
+ *
14
+ * The value is taken VERBATIM (no escape-sequence interpretation, no newline
15
+ * collapsing). Only the whole-string leading/trailing whitespace is trimmed,
16
+ * and that trimming happens at the point of use (lib/commit.js) so that empty /
17
+ * whitespace-only values still fall through to the "No commit message provided"
18
+ * path. Here we return the raw string.
19
+ *
20
+ * Last occurrence wins if the flag appears more than once.
21
+ *
22
+ * @param {string[]} argv
23
+ * @returns {{ message: string|null, valueIndices: Set<number> }}
24
+ * message — the raw message string, or null if -m/--message not present.
25
+ * valueIndices — argv positions of the VALUE token only (the space-separated
26
+ * form's value). The flag tokens (-m/--message) are NOT included
27
+ * because (a) they start with '-' so command derivation already
28
+ * skips them, and (b) the flag-validator still needs to validate
29
+ * them against the allow-list. The value token is recorded so
30
+ * command derivation doesn't mistake it for a command/bump type
31
+ * and so the validator skips it if it happens to start with '-'.
32
+ */
33
+ export function extractMessageFlag(argv) {
34
+ let message = null;
35
+ const valueIndices = new Set();
36
+ for (let i = 0; i < argv.length; i++) {
37
+ const arg = argv[i];
38
+ if (arg === '-m' || arg === '--message') {
39
+ if (i + 1 < argv.length) {
40
+ message = argv[i + 1];
41
+ valueIndices.add(i + 1);
42
+ } else {
43
+ // Flag given with no following token — treat as empty so it falls
44
+ // through to the "No commit message provided" error rather than
45
+ // silently consuming nothing.
46
+ message = '';
47
+ }
48
+ } else if (arg.startsWith('--message=')) {
49
+ message = arg.slice('--message='.length);
50
+ valueIndices.add(i);
51
+ }
52
+ }
53
+ return { message, valueIndices };
54
+ }
package/lib/bootstrap.js CHANGED
@@ -612,6 +612,11 @@ MAIASS_STAGINGBRANCH=${config.branches.staging}
612
612
 
613
613
  # Example: Personal debug settings
614
614
  #MAIASS_DEBUG=true
615
+
616
+ # Example: devlog tagging (optional)
617
+ #MAIASS_DEVLOG_CLIENT="yourclient"
618
+ #MAIASS_DEVLOG_SUBCLIENT=""
619
+ #MAIASS_DEVLOG_PROJECT="yourproject"
615
620
  `;
616
621
  fs.writeFileSync('.env.maiass.local', localContent, 'utf8');
617
622
  log.success(SYMBOLS.CHECKMARK, 'Created .env.maiass.local for personal settings');
package/lib/commit.js CHANGED
@@ -16,6 +16,45 @@ import { logCommit } from './devlog.js';
16
16
  import colors from './colors.js';
17
17
  import chalk from 'chalk';
18
18
 
19
+ /**
20
+ * Pick the AI model used for commit message generation.
21
+ *
22
+ * Resolution order:
23
+ * 1. MAIASS_AI_COMMIT_MODEL — explicit per-commit override. Wins absolutely.
24
+ * 2. MAIASS_AI_MODEL — legacy global override. Preserves prior behaviour for
25
+ * users who configured it explicitly.
26
+ * 3. Auto-pick by diff size (post-truncation char count of the staged diff
27
+ * that will actually be sent to the model):
28
+ * < 30000 chars -> gpt-4o-mini (tier 1)
29
+ * 30000..100000 -> gpt-4-turbo (tier 2)
30
+ * > 100000 -> gpt-4o (tier 3)
31
+ *
32
+ * In practice MAIASS_AI_MAX_CHARACTERS defaults to 8000, so tier 1 is the
33
+ * common case. Tiers 2 and 3 only fire when a user has raised that cap. The
34
+ * ladder is forward-compatible with future increases.
35
+ *
36
+ * Returns { model, tier, source } where source is 'override' | 'legacy' |
37
+ * 'auto', and tier is 1/2/3 (only meaningful when source === 'auto').
38
+ */
39
+ export function pickCommitModel(diffLength, env = process.env) {
40
+ const override = env.MAIASS_AI_COMMIT_MODEL;
41
+ if (override && override.trim()) {
42
+ return { model: override.trim(), tier: null, source: 'override' };
43
+ }
44
+ const legacy = env.MAIASS_AI_MODEL;
45
+ if (legacy && legacy.trim()) {
46
+ return { model: legacy.trim(), tier: null, source: 'legacy' };
47
+ }
48
+ const len = typeof diffLength === 'number' && diffLength >= 0 ? diffLength : 0;
49
+ if (len < 30000) {
50
+ return { model: 'gpt-4o-mini', tier: 1, source: 'auto' };
51
+ }
52
+ if (len <= 100000) {
53
+ return { model: 'gpt-4-turbo', tier: 2, source: 'auto' };
54
+ }
55
+ return { model: 'gpt-4o', tier: 3, source: 'auto' };
56
+ }
57
+
19
58
  /**
20
59
  * Simple spinner for AI API calls
21
60
  */
@@ -291,7 +330,8 @@ async function getAICommitSuggestion(gitInfo) {
291
330
  const aiHost = process.env.MAIASS_AI_HOST || 'https://pound.maiass.net';
292
331
  const aiPath = process.env.MAIASS_AI_PATH || '/proxy';
293
332
  const aiEndpoint = aiHost + aiPath;
294
- const aiModel = process.env.MAIASS_AI_MODEL || 'gpt-3.5-turbo';
333
+ // aiModel is resolved AFTER the diff is collected and truncated — the
334
+ // auto-pick layer needs the final diff size. See pickCommitModel() below.
295
335
  const aiTemperature = parseFloat(process.env.MAIASS_AI_TEMPERATURE || '0.7');
296
336
  const commitMessageStyle = process.env.MAIASS_AI_COMMIT_MESSAGE_STYLE || 'bullet';
297
337
  const maxCharacters = parseInt(process.env.MAIASS_AI_MAX_CHARACTERS || '8000');
@@ -372,7 +412,29 @@ async function getAICommitSuggestion(gitInfo) {
372
412
  log.info(SYMBOLS.INFO, `Git diff truncated to ${maxCharacters} characters`);
373
413
  }
374
414
 
415
+ // Pick the AI model AFTER truncation — the auto-pick layer reads the final
416
+ // diff size that will actually be sent to the model. MAIASS_AI_COMMIT_MODEL
417
+ // overrides everything; MAIASS_AI_MODEL is the legacy fallback; otherwise
418
+ // we ladder based on diff length.
419
+ const modelPick = pickCommitModel(gitDiff.length);
420
+ const aiModel = modelPick.model;
421
+
375
422
  if (debugMode) {
423
+ let pickSummary;
424
+ if (modelPick.source === 'override') {
425
+ pickSummary = `[MAIASS DEBUG] AI commit model: ${aiModel} (override via MAIASS_AI_COMMIT_MODEL)`;
426
+ } else if (modelPick.source === 'legacy') {
427
+ pickSummary = `[MAIASS DEBUG] AI commit model: ${aiModel} (legacy MAIASS_AI_MODEL)`;
428
+ } else {
429
+ const ranges = {
430
+ 1: '< 30000',
431
+ 2: '30000-100000',
432
+ 3: '> 100000'
433
+ };
434
+ pickSummary = `[MAIASS DEBUG] AI commit model: ${aiModel} (tier ${modelPick.tier}, diff ${gitDiff.length} chars, threshold ${ranges[modelPick.tier]})`;
435
+ }
436
+ log.debug(SYMBOLS.INFO, pickSummary);
437
+
376
438
  log.debug(SYMBOLS.INFO, '[MAIASS DEBUG] --- AI Commit Suggestion Context ---');
377
439
  log.debug(SYMBOLS.INFO, `[MAIASS DEBUG] Model: ${aiModel}`);
378
440
  log.debug(SYMBOLS.INFO, `[MAIASS DEBUG] Temperature: ${aiTemperature}`);
@@ -628,20 +690,47 @@ async function getMultiLineCommitMessage(jiraTicket) {
628
690
  * @returns {Promise<string>} Final commit message
629
691
  */
630
692
  async function getCommitMessage(gitInfo, options = {}) {
631
- const { silent = false } = options;
632
-
693
+ const { silent = false, providedMessage = null } = options;
694
+
633
695
  // Load environment config to ensure tokens are loaded from secure storage
634
696
  loadEnvironmentConfig();
635
-
697
+
636
698
  const aiMode = process.env.MAIASS_AI_MODE || 'ask';
637
699
  const maiassToken = process.env.MAIASS_AI_TOKEN;
638
700
  const jiraTicket = gitInfo.jiraTicket;
639
-
701
+
640
702
  // Display JIRA ticket if found
641
703
  if (jiraTicket) {
642
704
  log.info(SYMBOLS.INFO, `Jira Ticket Number: ${jiraTicket}`);
643
705
  }
644
-
706
+
707
+ // -m / --message: verbatim, non-interactive commit message (MAI-XX parity).
708
+ //
709
+ // When supplied, this short-circuits BEFORE any AI proxy call and before any
710
+ // interactive prompt — so it works with no token and no credit, and never
711
+ // hits the network. The message is used EXACTLY as given except for trimming
712
+ // leading/trailing whitespace of the whole string: internal newlines,
713
+ // indentation and blank lines are preserved verbatim (do NOT reformat — the
714
+ // canonical "title\n - bullet" layout with no blank line between title and
715
+ // bullets must survive intact for changelog parsing). Escape sequences are
716
+ // NOT interpreted (a literal "\n" stays literal).
717
+ //
718
+ // Empty / whitespace-only → return '' so the caller emits the existing
719
+ // "No commit message provided" error and does not commit.
720
+ if (providedMessage !== null && providedMessage !== undefined) {
721
+ const trimmed = providedMessage.trim();
722
+ if (!trimmed) {
723
+ return '';
724
+ }
725
+ // Prepend the Jira ticket if detected and not already present (matches the
726
+ // AI / manual paths). The caller (handleStagedCommit) also guards against
727
+ // double-prepend via startsWith, so this is idempotent.
728
+ if (jiraTicket && !trimmed.startsWith(jiraTicket)) {
729
+ return `${jiraTicket} ${trimmed}`;
730
+ }
731
+ return trimmed;
732
+ }
733
+
645
734
  let useAI = false;
646
735
  let aiSuggestion = null;
647
736
 
@@ -787,27 +876,34 @@ async function getCommitMessage(gitInfo, options = {}) {
787
876
  * @returns {Promise<boolean>} True if commit was successful
788
877
  */
789
878
  async function handleStagedCommit(gitInfo, options = {}) {
790
- const { silent = false } = options;
791
- // Check if there are actually staged changes
792
- if (!gitInfo.status || gitInfo.status.stagedCount === 0) {
879
+ const { silent = false, dryRun = false, forceStaged = false, providedMessage = null } = options;
880
+ // Check if there are actually staged changes.
881
+ // In dry-run with forceStaged, nothing was really staged (the `git add` was
882
+ // only previewed), so skip this guard — the working-tree changes are what
883
+ // *would* be committed.
884
+ if (!forceStaged && (!gitInfo.status || gitInfo.status.stagedCount === 0)) {
793
885
  log.warning(SYMBOLS.INFO, 'Nothing to commit, working tree clean');
794
886
  return true;
795
887
  }
796
-
797
- // Show staged changes
798
- const stagedOutput = executeGitCommand('git diff --cached --name-status', true);
888
+
889
+ // Show the changes that are (or would be) committed. For a forced dry run
890
+ // nothing is actually staged, so preview the working-tree changes instead.
891
+ const diffCommand = forceStaged
892
+ ? 'git diff HEAD --name-status'
893
+ : 'git diff --cached --name-status';
894
+ const stagedOutput = executeGitCommand(diffCommand, true);
799
895
  if (!stagedOutput) {
800
896
  log.warning(SYMBOLS.INFO, 'No staged changes to show');
801
897
  return true;
802
898
  }
803
-
804
- log.critical(SYMBOLS.INFO, 'Staged changes detected:');
805
-
899
+
900
+ log.critical(SYMBOLS.INFO, forceStaged ? 'Changes that would be committed:' : 'Staged changes detected:');
901
+
806
902
  // Display the staged changes
807
903
  console.log(stagedOutput);
808
-
904
+
809
905
  // Get commit message
810
- const commitMessage = await getCommitMessage(gitInfo, { silent });
906
+ const commitMessage = await getCommitMessage(gitInfo, { silent, providedMessage });
811
907
  if (!commitMessage) {
812
908
  log.error(SYMBOLS.CROSS, 'No commit message provided');
813
909
  return false;
@@ -826,24 +922,28 @@ async function handleStagedCommit(gitInfo, options = {}) {
826
922
  if (jiraTicket && finalCommitMessage && !finalCommitMessage.startsWith(jiraTicket)) {
827
923
  finalCommitMessage = `${jiraTicket} ${finalCommitMessage}`;
828
924
  }
829
- // Write commit message to a temp file on all platforms — avoids shell quoting and injection risks
830
- {
925
+ if (dryRun) {
926
+ // Preview the commit instead of performing it
927
+ const previewMessage = finalCommitMessage.split('\n')[0];
928
+ console.log(colors.BBlue(`${SYMBOLS.INFO} Dry run - would commit with message: "${previewMessage}"`));
929
+ } else {
930
+ // Write commit message to a temp file on all platforms — avoids shell quoting and injection risks
831
931
  const tmpFile = path.join(os.tmpdir(), `maiass-commit-msg-${Date.now()}.txt`);
832
932
  fs.writeFileSync(tmpFile, finalCommitMessage, { encoding: 'utf8' });
833
933
  result = executeGitCommand(`git commit -F "${tmpFile}"`, quietMode);
834
934
  fs.unlinkSync(tmpFile);
935
+
936
+ if (result === null) {
937
+ log.error(SYMBOLS.CROSS, 'Commit failed');
938
+ return false;
939
+ }
940
+
941
+ log.success(SYMBOLS.CHECKMARK, 'Changes committed successfully');
942
+
943
+ // Log the commit to devlog.sh (equivalent to logthis in maiass.sh)
944
+ logCommit(commitMessage, gitInfo);
835
945
  }
836
-
837
- if (result === null) {
838
- log.error(SYMBOLS.CROSS, 'Commit failed');
839
- return false;
840
- }
841
-
842
- log.success(SYMBOLS.CHECKMARK, 'Changes committed successfully');
843
-
844
- // Log the commit to devlog.sh (equivalent to logthis in maiass.sh)
845
- logCommit(commitMessage, gitInfo);
846
-
946
+
847
947
  // Ask about pushing to remote
848
948
  if (remoteExists('origin')) {
849
949
  let reply;
@@ -872,13 +972,17 @@ async function handleStagedCommit(gitInfo, options = {}) {
872
972
  }
873
973
 
874
974
  if (reply === 'y') {
875
-
876
- const pushResult = executeGitCommand(`git push --set-upstream origin ${gitInfo.branch}`, false);
877
- if (pushResult !== null) {
878
- log.success(SYMBOLS.CHECKMARK, 'Commit pushed.');
975
+ if (dryRun) {
976
+ // Preview the push instead of performing it
977
+ console.log(colors.BBlue(`${SYMBOLS.INFO} Dry run - would push to origin ${gitInfo.branch} (git push --set-upstream origin ${gitInfo.branch})`));
879
978
  } else {
880
- log.error(SYMBOLS.CROSS, 'Push failed');
881
- return false;
979
+ const pushResult = executeGitCommand(`git push --set-upstream origin ${gitInfo.branch}`, false);
980
+ if (pushResult !== null) {
981
+ log.success(SYMBOLS.CHECKMARK, 'Commit pushed.');
982
+ } else {
983
+ log.error(SYMBOLS.CROSS, 'Push failed');
984
+ return false;
985
+ }
882
986
  }
883
987
  }
884
988
  } else {
@@ -898,7 +1002,7 @@ async function handleStagedCommit(gitInfo, options = {}) {
898
1002
  * @returns {Promise<boolean>} True if process completed successfully
899
1003
  */
900
1004
  export async function commitThis(options = {}) {
901
- const { autoStage = false, commitsOnly = false, silent = false } = options;
1005
+ const { autoStage = false, commitsOnly = false, silent = false, dryRun = false, providedMessage = null } = options;
902
1006
 
903
1007
  // Get git information
904
1008
  const gitInfo = getGitInfo();
@@ -947,21 +1051,26 @@ export async function commitThis(options = {}) {
947
1051
  reply = await getSingleCharInput('Do you want to stage and commit them? [y/N] ');
948
1052
  }
949
1053
  if (reply === 'y') {
1054
+ if (dryRun) {
1055
+ // Preview the stage instead of performing it
1056
+ console.log(colors.BBlue(`${SYMBOLS.INFO} Dry run - would stage all changes (git add -A)`));
1057
+ return await handleStagedCommit(gitInfo, { silent, dryRun, forceStaged: true, providedMessage });
1058
+ }
950
1059
  // Stage all changes
951
1060
  const stageResult = executeGitCommand('git add -A', false);
952
1061
  if (stageResult === null) {
953
1062
  log.error(SYMBOLS.CROSS, 'Failed to stage changes');
954
1063
  return false;
955
1064
  }
956
-
1065
+
957
1066
  // Refresh git info to get updated status
958
1067
  const updatedGitInfo = getGitInfo();
959
- return await handleStagedCommit(updatedGitInfo, { silent });
1068
+ return await handleStagedCommit(updatedGitInfo, { silent, dryRun, providedMessage });
960
1069
  } else {
961
1070
  // Check if there are staged changes to commit
962
1071
  if (status.stagedCount > 0) {
963
1072
  log.info(SYMBOLS.INFO, 'Proceeding with staged changes only');
964
- return await handleStagedCommit(gitInfo, { silent });
1073
+ return await handleStagedCommit(gitInfo, { silent, dryRun, providedMessage });
965
1074
  }
966
1075
 
967
1076
  // Handle the case where user declined to stage and there are no staged changes
@@ -979,20 +1088,25 @@ export async function commitThis(options = {}) {
979
1088
  }
980
1089
  }
981
1090
  } else {
1091
+ if (dryRun) {
1092
+ // Preview the stage instead of performing it
1093
+ console.log(colors.BBlue(`${SYMBOLS.INFO} Dry run - would stage all changes (git add -A)`));
1094
+ return await handleStagedCommit(gitInfo, { silent, dryRun, forceStaged: true, providedMessage });
1095
+ }
982
1096
  // Auto-stage all changes
983
1097
  const stageResult = executeGitCommand('git add -A', false);
984
1098
  if (stageResult === null) {
985
1099
  console.log(colors.Red(`${SYMBOLS.CROSS} Failed to stage changes`));
986
1100
  return false;
987
1101
  }
988
-
1102
+
989
1103
  // Refresh git info and commit
990
1104
  const updatedGitInfo = getGitInfo();
991
- return await handleStagedCommit(updatedGitInfo, { silent });
1105
+ return await handleStagedCommit(updatedGitInfo, { silent, dryRun, providedMessage });
992
1106
  }
993
1107
  } else if (status.stagedCount > 0) {
994
1108
  // Only staged changes present, proceed directly to commit
995
- return await handleStagedCommit(gitInfo, { silent });
1109
+ return await handleStagedCommit(gitInfo, { silent, dryRun, providedMessage });
996
1110
  }
997
1111
 
998
1112
  return true;
@@ -81,7 +81,7 @@ function displayConfig(config, options = {}) {
81
81
  // Group by category for better display
82
82
  const categories = {
83
83
  'Core System': ['MAIASS_DEBUG', 'MAIASS_VERBOSITY', 'MAIASS_LOGGING', 'MAIASS_BRAND'],
84
- 'AI Integration': ['MAIASS_AI_MODE', 'MAIASS_AI_TOKEN', 'MAIASS_AI_MODEL', 'MAIASS_AI_TEMPERATURE', 'MAIASS_AI_HOST', 'MAIASS_AI_MAX_CHARACTERS', 'MAIASS_AI_COMMIT_MESSAGE_STYLE'],
84
+ 'AI Integration': ['MAIASS_AI_MODE', 'MAIASS_AI_TOKEN', 'MAIASS_AI_MODEL', 'MAIASS_AI_COMMIT_MODEL', 'MAIASS_AI_TEMPERATURE', 'MAIASS_AI_HOST', 'MAIASS_AI_MAX_CHARACTERS', 'MAIASS_AI_COMMIT_MESSAGE_STYLE'],
85
85
  'Git Branches': ['MAIASS_DEVELOPBRANCH', 'MAIASS_STAGINGBRANCH', 'MAIASS_MAINBRANCH'],
86
86
  'Repository Settings': ['MAIASS_REPO_TYPE', 'MAIASS_GITHUB_OWNER', 'MAIASS_GITHUB_REPO', 'MAIASS_BITBUCKET_WORKSPACE', 'MAIASS_BITBUCKET_REPO_SLUG'],
87
87
  'Pull Requests': ['MAIASS_DEVELOP_PULLREQUESTS'],
@@ -112,7 +112,7 @@ export function writeConfig(configPath, config, options = {}) {
112
112
  // Group variables by category for better organization
113
113
  const categories = {
114
114
  'Core': ['MAIASS_DEBUG', 'MAIASS_VERBOSITY', 'MAIASS_LOGGING', 'MAIASS_BRAND'],
115
- 'AI': ['MAIASS_AI_MODE', 'MAIASS_AI_TOKEN', 'MAIASS_AI_MODEL', 'MAIASS_AI_TEMPERATURE', 'MAIASS_AI_ENDPOINT', 'MAIASS_AI_MAX_CHARACTERS', 'MAIASS_AI_COMMIT_MESSAGE_STYLE'],
115
+ 'AI': ['MAIASS_AI_MODE', 'MAIASS_AI_TOKEN', 'MAIASS_AI_MODEL', 'MAIASS_AI_COMMIT_MODEL', 'MAIASS_AI_TEMPERATURE', 'MAIASS_AI_ENDPOINT', 'MAIASS_AI_MAX_CHARACTERS', 'MAIASS_AI_COMMIT_MESSAGE_STYLE'],
116
116
  'Branches': ['MAIASS_DEVELOPBRANCH', 'MAIASS_STAGINGBRANCH', 'MAIASS_MAINBRANCH'],
117
117
  'Repository': ['MAIASS_REPO_TYPE', 'MAIASS_GITHUB_OWNER', 'MAIASS_GITHUB_REPO', 'MAIASS_BITBUCKET_WORKSPACE', 'MAIASS_BITBUCKET_REPO_SLUG'],
118
118
  'Pull Requests': ['MAIASS_DEVELOP_PULLREQUESTS'],
@@ -89,13 +89,19 @@ export function suggestFlag(unknown, candidates) {
89
89
  * Validate the argv flags for a given subcommand.
90
90
  * @param {string[]} args - The raw argv tail (process.argv.slice(2))
91
91
  * @param {string[]} subcommandFlags - FLAGS export from the active subcommand handler
92
+ * @param {Set<number>} [ignoreIndices] - argv positions to skip entirely. Used
93
+ * for option VALUES that may legitimately start with '-' (e.g. the -m/--message
94
+ * value, where a caller might pass `-m "-- not a flag --"`). Without this, such
95
+ * a value token would be misread as an unknown flag and rejected.
92
96
  * @returns {{valid: true} | {valid: false, flag: string, suggestion: string|null, validFlags: string[]}}
93
97
  */
94
- export function validateFlags(args, subcommandFlags = []) {
98
+ export function validateFlags(args, subcommandFlags = [], ignoreIndices = new Set()) {
95
99
  // Dedupe so callers don't have to worry about overlap between the always-
96
100
  // valid set and a subcommand's FLAGS (e.g. account-info also lists --json).
97
101
  const validFlags = Array.from(new Set([...ALWAYS_VALID_FLAGS, ...subcommandFlags]));
98
- for (const arg of args) {
102
+ for (let i = 0; i < args.length; i++) {
103
+ const arg = args[i];
104
+ if (ignoreIndices.has(i)) continue;
99
105
  if (!arg.startsWith('-')) continue;
100
106
  // Support `--tag=value` form — only the name portion is validated.
101
107
  const flagName = arg.split('=')[0];
@@ -19,6 +19,10 @@ export const FLAGS = [
19
19
  '--force', '-f',
20
20
  '--silent', '-s',
21
21
  '--tag', '-t',
22
+ // -m / --message <value>: supply the commit message verbatim, non-interactively
23
+ // (MAI-XX node+bash parity). Bypasses AI + interactive prompt. Works with the
24
+ // default workflow and commits-only (-c / -ac).
25
+ '--message', '-m',
22
26
  ];
23
27
 
24
28
  /**
@@ -34,7 +38,9 @@ export async function handleMaiassCommand(args) {
34
38
  'dry-run': dryRun,
35
39
  tag,
36
40
  force,
37
- silent
41
+ silent,
42
+ // Verbatim commit message from -m/--message (MAI-XX). null when not supplied.
43
+ message: providedMessage = null
38
44
  } = args;
39
45
 
40
46
  // Extract version bump from positional arguments if provided
@@ -63,7 +69,8 @@ export async function handleMaiassCommand(args) {
63
69
  dryRun,
64
70
  tag,
65
71
  force,
66
- silent
72
+ silent,
73
+ providedMessage
67
74
  });
68
75
 
69
76
  if (result.success) {
@@ -246,16 +246,18 @@ async function validateAndHandleBranching(options = {}) {
246
246
  * @returns {Promise<Object>} Commit result
247
247
  */
248
248
  async function handleCommitWorkflow(branchInfo, options = {}) {
249
- const { commitsOnly = false, autoStage = false, silent = false } = options;
250
-
249
+ const { commitsOnly = false, autoStage = false, silent = false, dryRun = false, providedMessage = null } = options;
250
+
251
251
  logger.header(SYMBOLS.INFO, 'Commit Workflow Phase');
252
-
252
+
253
253
  try {
254
254
  // Run the commit workflow
255
255
  const commitSuccess = await commitThis({
256
256
  autoStage,
257
257
  commitsOnly,
258
- silent
258
+ silent,
259
+ dryRun,
260
+ providedMessage
259
261
  });
260
262
 
261
263
  // Check if commit workflow succeeded
@@ -829,7 +831,9 @@ export async function runMaiassPipeline(options = {}) {
829
831
  dryRun = false,
830
832
  tag = false,
831
833
  force = false,
832
- silent = false
834
+ silent = false,
835
+ // Verbatim commit message from -m/--message (MAI-XX). null = not supplied.
836
+ providedMessage = null
833
837
  } = options;
834
838
 
835
839
  // Display branded header with MAIASS's own version (not project version)
@@ -867,7 +871,7 @@ export async function runMaiassPipeline(options = {}) {
867
871
 
868
872
  // Phase 2: Commit Workflow
869
873
  logger.debug('Phase 2: Commit Workflow');
870
- const commitResult = await handleCommitWorkflow(branchInfo, { commitsOnly, autoStage, silent });
874
+ const commitResult = await handleCommitWorkflow(branchInfo, { commitsOnly, autoStage, silent, dryRun, providedMessage });
871
875
 
872
876
  if (!commitResult.success) {
873
877
  // Check if it was cancelled by user vs actual failure
@@ -886,9 +890,13 @@ export async function runMaiassPipeline(options = {}) {
886
890
  // If commits-only mode, stop here
887
891
  if (commitsOnly) {
888
892
  console.log();
889
- console.log(colors.BGreen(`${SYMBOLS.CHECKMARK} Commits-only mode completed successfully`));
893
+ if (dryRun) {
894
+ console.log(colors.BGreen(`${SYMBOLS.CHECKMARK} Commits-only mode dry run completed (no changes made)`));
895
+ } else {
896
+ console.log(colors.BGreen(`${SYMBOLS.CHECKMARK} Commits-only mode completed successfully`));
897
+ }
890
898
  console.log(colors.BBlue(`${SYMBOLS.INFO} Current branch: ${getCurrentBranch()}`));
891
- return { success: true, phase: 'commits-only' };
899
+ return { success: true, phase: 'commits-only', dryRun };
892
900
  }
893
901
 
894
902
  console.log();
@@ -22,6 +22,7 @@ export const MAIASS_VARIABLES = {
22
22
  'MAIASS_AI_MODE': { default: 'ask', description: 'AI interaction mode' },
23
23
  'MAIASS_AI_TOKEN': { default: '', description: 'AI API token', sensitive: true },
24
24
  'MAIASS_AI_MODEL': { default: 'gpt-3.5-turbo', description: 'AI model to use' },
25
+ 'MAIASS_AI_COMMIT_MODEL': { default: '', description: 'Override the AI model used for commit message generation. If set, skips auto-pick. Defaults to MAIASS_AI_MODEL if set, otherwise auto-picks by diff size.' },
25
26
  'MAIASS_AI_TEMPERATURE': { default: '0.7', description: 'AI temperature setting' },
26
27
  'MAIASS_AI_MAX_CHARACTERS': { default: '8000', description: 'Max characters for AI requests' },
27
28
  'MAIASS_AI_TIMEOUT': { default: '30', description: 'AI request timeout in seconds' },
@@ -33,7 +34,7 @@ export const MAIASS_VARIABLES = {
33
34
  'MAIASS_VERSION_PRIMARY_FILE': { default: '', description: 'Primary version file path' },
34
35
  'MAIASS_VERSION_PRIMARY_TYPE': { default: '', description: 'Primary version file type' },
35
36
  'MAIASS_VERSION_PRIMARY_LINE_START': { default: '', description: 'Line start pattern for version' },
36
- 'MAIASS_VERSION_SECONDARY_FILES': { default: '', description: 'Secondary version files (comma-separated)' },
37
+ 'MAIASS_VERSION_SECONDARY_FILES': { default: '', description: 'Secondary version files (pipe-separated file:type:pattern entries)' },
37
38
 
38
39
  // Branch configuration
39
40
  'MAIASS_DEVELOPBRANCH': { default: 'develop', description: 'Development branch name' },
package/maiass.mjs CHANGED
@@ -55,11 +55,25 @@ import { SYMBOLS } from './lib/symbols.js';
55
55
  import { bootstrapProject } from './lib/bootstrap.js';
56
56
  import { createGithubAction, showGitlabExcerpt, showBitbucketExcerpt } from './lib/ci-templates.js';
57
57
  import { validateFlags } from './lib/flag-validator.js';
58
+ import { extractMessageFlag } from './lib/arg-utils.js';
58
59
 
59
60
  // Simple CLI setup for pkg compatibility
60
61
  const args = process.argv.slice(2);
61
- // Skip flags (starting with -) to find the first meaningful argument
62
- const firstArg = args.find(a => !a.startsWith('-'));
62
+
63
+ // -m / --message <value>: a verbatim commit message supplied non-interactively
64
+ // (MAI-XX node+bash parity). The flag CONSUMES the next token as its value, so
65
+ // that value must NOT be treated as the command name / version-bump type during
66
+ // command derivation below. Supported forms: `-m <value>`, `--message <value>`,
67
+ // `--message=<value>`. Single value only (no git-style repeated -m).
68
+ //
69
+ // extractMessageFlag returns { message, valueIndices } where valueIndices marks
70
+ // argv positions that belong to the flag (the flag token and, for the space-
71
+ // separated form, its value) so command derivation can skip them.
72
+ const { message: providedMessage, valueIndices: messageArgIndices } = extractMessageFlag(args);
73
+
74
+ // Skip flags (starting with -) AND the -m/--message value token to find the
75
+ // first meaningful positional argument (command / version-bump).
76
+ const firstArg = args.find((a, i) => !a.startsWith('-') && !messageArgIndices.has(i));
63
77
 
64
78
  // Handle --setup/--bootstrap flag early
65
79
  if (args.includes('--setup') || args.includes('--bootstrap')) {
@@ -146,7 +160,10 @@ const SUBCOMMAND_FLAGS = {
146
160
  };
147
161
 
148
162
  const activeSubcommandFlags = SUBCOMMAND_FLAGS[command] ?? [];
149
- const flagValidation = validateFlags(args, activeSubcommandFlags);
163
+ // Skip the -m/--message value token during validation — it may legitimately
164
+ // begin with '-' (e.g. `-m "-- breaking change --"`) and must not be treated
165
+ // as an unknown flag.
166
+ const flagValidation = validateFlags(args, activeSubcommandFlags, messageArgIndices);
150
167
 
151
168
  if (!flagValidation.valid) {
152
169
  console.error(colors.Red(`${SYMBOLS.CROSS} Error: Unrecognized option '${flagValidation.flag}' for command '${command}'`));
@@ -167,6 +184,14 @@ if (!flagValidation.valid) {
167
184
  process.exit(1);
168
185
  }
169
186
 
187
+ // An explicitly-supplied but empty/whitespace-only -m/--message is a usage
188
+ // error — fail fast with a non-zero exit (parity with bash, which aborts here).
189
+ // An absent flag (providedMessage === null) is unaffected.
190
+ if (providedMessage !== null && providedMessage.trim() === '') {
191
+ console.error(colors.Red(`${SYMBOLS.CROSS} Error: --message/-m requires a non-empty value`));
192
+ process.exit(1);
193
+ }
194
+
170
195
  // Apply auto-mode env vars now that validation has passed. Setting these
171
196
  // before validation would leak MAIASS_AUTO_* into process.env for commands
172
197
  // that reject --auto (e.g. `account-info --auto`) — see MAI-43 code review.
@@ -221,6 +246,8 @@ if (args.includes('--help') || args.includes('-h') || command === 'help') {
221
246
  console.log(' --auto, -a Full auto — stage, commit, push, merge to develop, bump version');
222
247
  console.log(' --auto-commit, -ac Auto-yes for commit phase only — stops after commit (no merge, no bump)');
223
248
  console.log(' --commits-only, -c Generate AI commits without version management');
249
+ console.log(' --message, -m <msg> Use this commit message verbatim (skips AI + the message prompt; no credit/token needed).');
250
+ console.log(' Supplies the message only; for fully unattended use combine with -ac (or --auto-stage).');
224
251
  console.log(' --auto-stage Automatically stage all changes');
225
252
  console.log(' --setup, --bootstrap Run interactive project setup');
226
253
  console.log(' --help, -h Show this help message');
@@ -263,7 +290,12 @@ if (args.includes('--show-bb-excerpt')) { showBitbucketExcerpt(); process.exit(
263
290
  '.env.maiass.local',
264
291
  `# .env.maiass.local — personal/local MAIASS settings (never committed)\n` +
265
292
  `# Generated on first run: ${new Date().toISOString()}\n` +
266
- `# Use this file for personal overrides, e.g. MAIASS_AI_MODE=autosuggest\n`,
293
+ `# Use this file for personal overrides, e.g. MAIASS_AI_MODE=autosuggest\n` +
294
+ `\n` +
295
+ `# Example: devlog tagging (optional)\n` +
296
+ `#MAIASS_DEVLOG_CLIENT="yourclient"\n` +
297
+ `#MAIASS_DEVLOG_SUBCLIENT=""\n` +
298
+ `#MAIASS_DEVLOG_PROJECT="yourproject"\n`,
267
299
  'utf8'
268
300
  );
269
301
 
@@ -326,7 +358,9 @@ if (args.includes('--show-bb-excerpt')) { showBitbucketExcerpt(); process.exit(
326
358
  case 'maiass':
327
359
  // Handle the main MAIASS workflow
328
360
  await handleMaiassCommand({
329
- _: process.argv.slice(2).filter(arg => !arg.startsWith('-')),
361
+ // Positionals must exclude the -m/--message value token (it doesn't start
362
+ // with '-' but is NOT a command/bump-type positional).
363
+ _: process.argv.slice(2).filter((arg, i) => !arg.startsWith('-') && !messageArgIndices.has(i)),
330
364
  // -ac is equivalent to -c (commits-only) plus auto-yes prompts
331
365
  'commits-only': args.includes('--commits-only') || args.includes('-c') || args.includes('--auto-commit') || args.includes('-ac'),
332
366
  'auto-stage': args.includes('--auto-stage'),
@@ -335,7 +369,10 @@ if (args.includes('--show-bb-excerpt')) { showBitbucketExcerpt(); process.exit(
335
369
  'dry-run': args.includes('--dry-run') || args.includes('-d'),
336
370
  force: args.includes('--force') || args.includes('-f'),
337
371
  silent: args.includes('--silent') || args.includes('-s'),
338
- tag: getArgValue(args, '--tag') || getArgValue(args, '-t')
372
+ tag: getArgValue(args, '--tag') || getArgValue(args, '-t'),
373
+ // Verbatim commit message supplied via -m/--message (or null). Threaded
374
+ // through the pipeline to the commit phase; bypasses AI + interactive.
375
+ message: providedMessage
339
376
  });
340
377
  break;
341
378
 
@@ -363,3 +400,6 @@ function getArgValue(args, flag) {
363
400
  }
364
401
  return null;
365
402
  }
403
+
404
+ // extractMessageFlag is imported from ./lib/arg-utils.js — extracted (MAI-51)
405
+ // so it can be unit-tested without booting the CLI on import.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "maiass",
3
3
  "type": "module",
4
- "version": "5.13.0",
4
+ "version": "5.14.2",
5
5
  "description": "AI commit messages, version bumps, and changelogs from one command. Stages, commits, merges, tags. Anonymous on first run — no email, no card.",
6
6
  "main": "maiass.mjs",
7
7
  "bin": {
@@ -50,8 +50,11 @@ jobs:
50
50
 
51
51
  - uses: actions/setup-node@v4
52
52
  with:
53
- node-version: '20'
54
- cache: 'npm'
53
+ node-version: '24'
54
+ # No cache: this workflow only does `npm install -g maiass`, which
55
+ # needs no project-level npm cache. Setting cache: 'npm' makes
56
+ # setup-node fail on projects without a Node lock file (e.g.
57
+ # WordPress/PHP plugins): "Dependencies lock file is not found".
55
58
 
56
59
  - name: Install maiass
57
60
  run: npm install -g maiass --no-fund --no-audit